From f3faa11dd2bab683e7d7e62f61c59f258c130681 Mon Sep 17 00:00:00 2001 From: Weiko Date: Wed, 18 Feb 2026 12:03:27 +0100 Subject: [PATCH] New field creates fields widget field (#18022) ## Context Introducing "NewFieldDefaultConfiguration" to FIELDS widget configurations ```typescript { isVisible: boolean; viewFieldGroupId: string | null; } ``` This configuration will define where a new field should be added (which section) and its default visibility inside FIELDS widget views. The new field position should always be at the end (meaning the last position for the view fields OR the last position of a viewFieldGroup) See "New fields" on this screenshot Layout V1 --- .../src/generated-metadata/graphql.ts | 7 + .../services/field-metadata.service.ts | 42 + ...-flat-page-layout-widget-to-create.util.ts | 8 +- ...uration-to-universal-configuration.util.ts | 38 +- ...-entity-to-flat-page-layout-widget.util.ts | 3 + ...e-layout-widget-to-update-or-throw.util.ts | 7 +- ...ew-fields-from-fields-widgets.util.spec.ts | 743 ++++++++++++++++++ ...at-view-fields-from-fields-widgets.util.ts | 183 +++++ .../object-metadata.service.ts | 11 +- .../dtos/fields-configuration.dto.ts | 28 +- .../services/page-layout-widget.service.ts | 22 +- .../page-layout-duplication.service.ts | 6 + .../services/page-layout-update.service.ts | 24 +- .../core/utils/seed-feature-flags.util.ts | 5 + ...t-page-layout-widget-metadata-maps.util.ts | 41 +- ...ge-layout-widget-action-handler.service.ts | 1 + ...ge-layout-widget-action-handler.service.ts | 2 + ...t-page-layout-widget-configuration.util.ts | 65 +- 18 files changed, 1203 insertions(+), 33 deletions(-) create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-view-field/utils/__tests__/compute-flat-view-fields-from-fields-widgets.util.spec.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-view-field/utils/compute-flat-view-fields-from-fields-widgets.util.ts diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 13467de80b..bc3602150a 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -1617,6 +1617,7 @@ export type FieldRichTextConfiguration = { export type FieldsConfiguration = { __typename?: 'FieldsConfiguration'; configurationType: WidgetConfigurationType; + newFieldDefaultConfiguration?: Maybe; viewId?: Maybe; }; @@ -3243,6 +3244,12 @@ export type NavigationMenuItem = { viewId?: Maybe; }; +export type NewFieldDefaultConfiguration = { + __typename?: 'NewFieldDefaultConfiguration'; + isVisible: Scalars['Boolean']; + viewFieldGroupId?: Maybe; +}; + export type NotesConfiguration = { __typename?: 'NotesConfiguration'; configurationType: WidgetConfigurationType; diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata.service.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata.service.ts index 501a6985d2..b66673e47c 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata.service.ts @@ -7,6 +7,7 @@ import { type FindOneOptions, type Repository } from 'typeorm'; import { ApplicationService } from 'src/engine/core-modules/application/services/application.service'; import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type'; +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; import { type CreateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/create-field.input'; import { type DeleteOneFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/delete-field.input'; import { type UpdateFieldInput } from 'src/engine/metadata-modules/field-metadata/dtos/update-field.input'; @@ -24,10 +25,12 @@ import { fromCreateFieldInputToFlatFieldMetadatasToCreate } from 'src/engine/met import { fromDeleteFieldInputToFlatFieldMetadatasToDelete } from 'src/engine/metadata-modules/flat-field-metadata/utils/from-delete-field-input-to-flat-field-metadatas-to-delete.util'; import { fromUpdateFieldInputToFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/from-update-field-input-to-flat-field-metadata.util'; import { throwOnFieldInputTranspilationsError } from 'src/engine/metadata-modules/flat-field-metadata/utils/throw-on-field-input-transpilations-error.util'; +import { computeFlatViewFieldsFromFieldsWidgets } from 'src/engine/metadata-modules/flat-view-field/utils/compute-flat-view-fields-from-fields-widgets.util'; import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; import { EMPTY_ORCHESTRATOR_FAILURE_REPORT } from 'src/engine/workspace-manager/workspace-migration/constant/empty-orchestrator-failure-report.constant'; import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception'; import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service'; +import { UniversalFlatViewField } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-view-field.type'; @Injectable() export class FieldMetadataService extends TypeOrmQueryService { @@ -333,9 +336,19 @@ export class FieldMetadataService extends TypeOrmQueryService ({ + objectMetadataUniversalIdentifier: + flatFieldMetadata.objectMetadataUniversalIdentifier, + fieldMetadataUniversalIdentifier: + flatFieldMetadata.universalIdentifier, + })), + flatPageLayoutWidgetMaps: existingFlatPageLayoutWidgetMaps, + flatViewFieldMaps: existingFlatViewFieldMaps, + flatViewMaps: existingFlatViewMaps, + flatViewFieldGroupMaps: existingFlatViewFieldGroupMaps, + applicationUniversalIdentifier: + resolvedOwnerFlatApplication.universalIdentifier, + }); + } + const validateAndBuildResult = await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration( { @@ -386,6 +423,11 @@ export class FieldMetadataService extends TypeOrmQueryService; export const fromCreatePageLayoutWidgetInputToFlatPageLayoutWidgetToCreate = ({ @@ -26,6 +29,7 @@ export const fromCreatePageLayoutWidgetInputToFlatPageLayoutWidgetToCreate = ({ flatPageLayoutTabMaps, flatObjectMetadataMaps, flatFieldMetadataMaps, + flatViewFieldGroupMaps, }: FromCreatePageLayoutWidgetInputToFlatPageLayoutWidgetToCreateArgs): FlatPageLayoutWidget => { const { pageLayoutTabId, ...createPageLayoutWidgetInput } = trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties( @@ -76,6 +80,8 @@ export const fromCreatePageLayoutWidgetInputToFlatPageLayoutWidgetToCreate = ({ configuration: createPageLayoutWidgetInput.configuration, fieldMetadataUniversalIdentifierById: flatFieldMetadataMaps.universalIdentifierById, + viewFieldGroupUniversalIdentifierById: + flatViewFieldGroupMaps.universalIdentifierById, shouldThrowOnMissingIdentifier: true, }), }; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-page-layout-widget/utils/from-page-layout-widget-configuration-to-universal-configuration.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-page-layout-widget/utils/from-page-layout-widget-configuration-to-universal-configuration.util.ts index 2eeedc361b..9d35406947 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-page-layout-widget/utils/from-page-layout-widget-configuration-to-universal-configuration.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-page-layout-widget/utils/from-page-layout-widget-configuration-to-universal-configuration.util.ts @@ -42,10 +42,12 @@ const getFieldMetadataUniversalIdentifier = ({ export const fromPageLayoutWidgetConfigurationToUniversalConfiguration = ({ configuration, fieldMetadataUniversalIdentifierById, + viewFieldGroupUniversalIdentifierById = {}, shouldThrowOnMissingIdentifier = false, }: { configuration: PageLayoutWidgetConfiguration; fieldMetadataUniversalIdentifierById: Partial>; + viewFieldGroupUniversalIdentifierById?: Partial>; shouldThrowOnMissingIdentifier?: boolean; }): UniversalPageLayoutWidgetConfiguration => { switch (configuration.configurationType) { @@ -200,9 +202,43 @@ export const fromPageLayoutWidgetConfigurationToUniversalConfiguration = ({ }; } + case WidgetConfigurationType.FIELDS: { + const { newFieldDefaultConfiguration, ...rest } = configuration; + + if (!isDefined(newFieldDefaultConfiguration)) { + return configuration; + } + + let viewFieldGroupUniversalIdentifier: string | null = null; + + if (isDefined(newFieldDefaultConfiguration.viewFieldGroupId)) { + viewFieldGroupUniversalIdentifier = + viewFieldGroupUniversalIdentifierById[ + newFieldDefaultConfiguration.viewFieldGroupId + ] ?? null; + + if ( + !isDefined(viewFieldGroupUniversalIdentifier) && + shouldThrowOnMissingIdentifier + ) { + throw new FlatEntityMapsException( + `View field group universal identifier not found for id: ${newFieldDefaultConfiguration.viewFieldGroupId}`, + FlatEntityMapsExceptionCode.RELATION_UNIVERSAL_IDENTIFIER_NOT_FOUND, + ); + } + } + + return { + ...rest, + newFieldDefaultConfiguration: { + isVisible: newFieldDefaultConfiguration.isVisible, + viewFieldGroupId: viewFieldGroupUniversalIdentifier, + }, + }; + } + case WidgetConfigurationType.VIEW: case WidgetConfigurationType.FIELD: - case WidgetConfigurationType.FIELDS: case WidgetConfigurationType.TIMELINE: case WidgetConfigurationType.TASKS: case WidgetConfigurationType.NOTES: diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-page-layout-widget/utils/from-page-layout-widget-entity-to-flat-page-layout-widget.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-page-layout-widget/utils/from-page-layout-widget-entity-to-flat-page-layout-widget.util.ts index 7836497aa6..0ddb217b56 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-page-layout-widget/utils/from-page-layout-widget-entity-to-flat-page-layout-widget.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-page-layout-widget/utils/from-page-layout-widget-entity-to-flat-page-layout-widget.util.ts @@ -12,6 +12,7 @@ import { type FromEntityToFlatEntityArgs } from 'src/engine/workspace-cache/type type FromPageLayoutWidgetEntityToFlatPageLayoutWidgetArgs = FromEntityToFlatEntityArgs<'pageLayoutWidget'> & { fieldMetadataUniversalIdentifierById: Partial>; + viewFieldGroupUniversalIdentifierById?: Partial>; }; export const fromPageLayoutWidgetEntityToFlatPageLayoutWidget = ({ @@ -20,6 +21,7 @@ export const fromPageLayoutWidgetEntityToFlatPageLayoutWidget = ({ pageLayoutTabIdToUniversalIdentifierMap, objectMetadataIdToUniversalIdentifierMap, fieldMetadataUniversalIdentifierById, + viewFieldGroupUniversalIdentifierById, }: FromPageLayoutWidgetEntityToFlatPageLayoutWidgetArgs): FlatPageLayoutWidget => { const pageLayoutWidgetEntityWithoutRelations = removePropertiesFromRecord( pageLayoutWidgetEntity, @@ -70,6 +72,7 @@ export const fromPageLayoutWidgetEntityToFlatPageLayoutWidget = ({ fromPageLayoutWidgetConfigurationToUniversalConfiguration({ configuration: pageLayoutWidgetEntityWithoutRelations.configuration, fieldMetadataUniversalIdentifierById, + viewFieldGroupUniversalIdentifierById, }); return { diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-page-layout-widget/utils/from-update-page-layout-widget-input-to-flat-page-layout-widget-to-update-or-throw.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-page-layout-widget/utils/from-update-page-layout-widget-input-to-flat-page-layout-widget-to-update-or-throw.util.ts index e35d98435e..63cd368623 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-page-layout-widget/utils/from-update-page-layout-widget-input-to-flat-page-layout-widget-to-update-or-throw.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-page-layout-widget/utils/from-update-page-layout-widget-input-to-flat-page-layout-widget-to-update-or-throw.util.ts @@ -30,12 +30,15 @@ export const fromUpdatePageLayoutWidgetInputToFlatPageLayoutWidgetToUpdateOrThro flatPageLayoutWidgetMaps, flatObjectMetadataMaps, flatFieldMetadataMaps, + flatViewFieldGroupMaps, }: { updatePageLayoutWidgetInput: UpdatePageLayoutWidgetInputWithId; flatPageLayoutWidgetMaps: FlatPageLayoutWidgetMaps; } & Pick< AllFlatEntityMaps, - 'flatObjectMetadataMaps' | 'flatFieldMetadataMaps' + | 'flatObjectMetadataMaps' + | 'flatFieldMetadataMaps' + | 'flatViewFieldGroupMaps' >): FlatPageLayoutWidget => { const { id: pageLayoutWidgetToUpdateId } = extractAndSanitizeObjectStringFields(rawUpdatePageLayoutWidgetInput, [ @@ -97,6 +100,8 @@ export const fromUpdatePageLayoutWidgetInputToFlatPageLayoutWidgetToUpdateOrThro configuration: flatPageLayoutWidgetToUpdate.configuration, fieldMetadataUniversalIdentifierById: flatFieldMetadataMaps.universalIdentifierById, + viewFieldGroupUniversalIdentifierById: + flatViewFieldGroupMaps.universalIdentifierById, }); } diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-view-field/utils/__tests__/compute-flat-view-fields-from-fields-widgets.util.spec.ts b/packages/twenty-server/src/engine/metadata-modules/flat-view-field/utils/__tests__/compute-flat-view-fields-from-fields-widgets.util.spec.ts new file mode 100644 index 0000000000..6b7e01cf39 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-view-field/utils/__tests__/compute-flat-view-fields-from-fields-widgets.util.spec.ts @@ -0,0 +1,743 @@ +import { DEFAULT_VIEW_FIELD_SIZE } from 'src/engine/metadata-modules/flat-view-field/constants/default-view-field-size.constant'; +import { type FlatViewFieldMaps } from 'src/engine/metadata-modules/flat-view-field/types/flat-view-field-maps.type'; +import { computeFlatViewFieldsFromFieldsWidgets } from 'src/engine/metadata-modules/flat-view-field/utils/compute-flat-view-fields-from-fields-widgets.util'; +import { type FlatPageLayoutWidgetMaps } from 'src/engine/metadata-modules/flat-page-layout-widget/types/flat-page-layout-widget-maps.type'; +import { type FlatViewFieldGroupMaps } from 'src/engine/metadata-modules/flat-view-field-group/types/flat-view-field-group-maps.type'; +import { type FlatViewMaps } from 'src/engine/metadata-modules/flat-view/types/flat-view-maps.type'; +import { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-configuration-type.type'; +import { WidgetType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-type.enum'; + +const APPLICATION_UNIVERSAL_IDENTIFIER = 'app-uid-1'; +const VIEW_ID = 'view-db-id-1'; +const VIEW_UNIVERSAL_IDENTIFIER = 'view-uid-1'; +const VIEW_FIELD_GROUP_ID = 'vfg-db-id-1'; +const VIEW_FIELD_GROUP_UNIVERSAL_IDENTIFIER = 'vfg-uid-1'; +const OBJECT_METADATA_UNIVERSAL_IDENTIFIER = 'obj-uid-1'; + +const buildEmptyFlatEntityMaps = () => ({ + byUniversalIdentifier: {}, + universalIdentifierById: {}, + universalIdentifiersByApplicationId: {}, +}); + +const buildFlatViewMaps = ( + entries: { id: string; universalIdentifier: string }[] = [], +): FlatViewMaps => + ({ + byUniversalIdentifier: Object.fromEntries( + entries.map((entry) => [ + entry.universalIdentifier, + { universalIdentifier: entry.universalIdentifier, id: entry.id }, + ]), + ), + universalIdentifierById: Object.fromEntries( + entries.map((entry) => [entry.id, entry.universalIdentifier]), + ), + universalIdentifiersByApplicationId: {}, + }) as unknown as FlatViewMaps; + +const buildFlatViewFieldGroupMaps = ( + entries: { id: string; universalIdentifier: string }[] = [], +): FlatViewFieldGroupMaps => + ({ + byUniversalIdentifier: Object.fromEntries( + entries.map((entry) => [ + entry.universalIdentifier, + { universalIdentifier: entry.universalIdentifier, id: entry.id }, + ]), + ), + universalIdentifierById: Object.fromEntries( + entries.map((entry) => [entry.id, entry.universalIdentifier]), + ), + universalIdentifiersByApplicationId: {}, + }) as unknown as FlatViewFieldGroupMaps; + +const buildFlatViewFieldMaps = ( + entries: { + universalIdentifier: string; + viewId: string; + viewFieldGroupId: string | null; + position: number; + deletedAt: string | null; + }[] = [], +): FlatViewFieldMaps => + ({ + byUniversalIdentifier: Object.fromEntries( + entries.map((entry) => [entry.universalIdentifier, entry]), + ), + universalIdentifierById: {}, + universalIdentifiersByApplicationId: {}, + }) as unknown as FlatViewFieldMaps; + +const buildFieldsWidget = ({ + widgetUniversalIdentifier = 'widget-uid-1', + objectMetadataUniversalIdentifier = OBJECT_METADATA_UNIVERSAL_IDENTIFIER, + viewId = VIEW_ID, + isVisible = true, + viewFieldGroupId = null as string | null, + deletedAt = null as string | null, +}: { + widgetUniversalIdentifier?: string; + objectMetadataUniversalIdentifier?: string; + viewId?: string | null; + isVisible?: boolean; + viewFieldGroupId?: string | null; + deletedAt?: string | null; +} = {}) => ({ + universalIdentifier: widgetUniversalIdentifier, + objectMetadataUniversalIdentifier, + type: WidgetType.FIELDS, + deletedAt, + configuration: { + configurationType: WidgetConfigurationType.FIELDS, + viewId, + newFieldDefaultConfiguration: { + isVisible, + viewFieldGroupId, + }, + }, + universalConfiguration: null, +}); + +const buildFlatPageLayoutWidgetMaps = ( + widgets: ReturnType[], +): FlatPageLayoutWidgetMaps => + ({ + byUniversalIdentifier: Object.fromEntries( + widgets.map((widget) => [widget.universalIdentifier, widget]), + ), + universalIdentifierById: {}, + universalIdentifiersByApplicationId: {}, + }) as unknown as FlatPageLayoutWidgetMaps; + +describe('computeFlatViewFieldsFromFieldsWidgets', () => { + describe('when no matching widgets exist', () => { + it('should return empty array when widget maps are empty', () => { + const result = computeFlatViewFieldsFromFieldsWidgets({ + fieldsToCreate: [ + { + objectMetadataUniversalIdentifier: + OBJECT_METADATA_UNIVERSAL_IDENTIFIER, + fieldMetadataUniversalIdentifier: 'field-uid-1', + }, + ], + flatPageLayoutWidgetMaps: + buildEmptyFlatEntityMaps() as unknown as FlatPageLayoutWidgetMaps, + flatViewFieldMaps: buildFlatViewFieldMaps(), + flatViewMaps: buildFlatViewMaps(), + flatViewFieldGroupMaps: buildFlatViewFieldGroupMaps(), + applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER, + }); + + expect(result).toEqual([]); + }); + + it('should return empty array when fieldsToCreate is empty', () => { + const result = computeFlatViewFieldsFromFieldsWidgets({ + fieldsToCreate: [], + flatPageLayoutWidgetMaps: buildFlatPageLayoutWidgetMaps([ + buildFieldsWidget(), + ]), + flatViewFieldMaps: buildFlatViewFieldMaps(), + flatViewMaps: buildFlatViewMaps([ + { id: VIEW_ID, universalIdentifier: VIEW_UNIVERSAL_IDENTIFIER }, + ]), + flatViewFieldGroupMaps: buildFlatViewFieldGroupMaps(), + applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER, + }); + + expect(result).toEqual([]); + }); + + it('should skip widgets for a different object', () => { + const result = computeFlatViewFieldsFromFieldsWidgets({ + fieldsToCreate: [ + { + objectMetadataUniversalIdentifier: 'other-obj-uid', + fieldMetadataUniversalIdentifier: 'field-uid-1', + }, + ], + flatPageLayoutWidgetMaps: buildFlatPageLayoutWidgetMaps([ + buildFieldsWidget(), + ]), + flatViewFieldMaps: buildFlatViewFieldMaps(), + flatViewMaps: buildFlatViewMaps([ + { id: VIEW_ID, universalIdentifier: VIEW_UNIVERSAL_IDENTIFIER }, + ]), + flatViewFieldGroupMaps: buildFlatViewFieldGroupMaps(), + applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER, + }); + + expect(result).toEqual([]); + }); + + it('should skip deleted widgets', () => { + const result = computeFlatViewFieldsFromFieldsWidgets({ + fieldsToCreate: [ + { + objectMetadataUniversalIdentifier: + OBJECT_METADATA_UNIVERSAL_IDENTIFIER, + fieldMetadataUniversalIdentifier: 'field-uid-1', + }, + ], + flatPageLayoutWidgetMaps: buildFlatPageLayoutWidgetMaps([ + buildFieldsWidget({ deletedAt: '2024-01-01T00:00:00.000Z' }), + ]), + flatViewFieldMaps: buildFlatViewFieldMaps(), + flatViewMaps: buildFlatViewMaps([ + { id: VIEW_ID, universalIdentifier: VIEW_UNIVERSAL_IDENTIFIER }, + ]), + flatViewFieldGroupMaps: buildFlatViewFieldGroupMaps(), + applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER, + }); + + expect(result).toEqual([]); + }); + + it('should skip widgets without viewId', () => { + const result = computeFlatViewFieldsFromFieldsWidgets({ + fieldsToCreate: [ + { + objectMetadataUniversalIdentifier: + OBJECT_METADATA_UNIVERSAL_IDENTIFIER, + fieldMetadataUniversalIdentifier: 'field-uid-1', + }, + ], + flatPageLayoutWidgetMaps: buildFlatPageLayoutWidgetMaps([ + buildFieldsWidget({ viewId: null }), + ]), + flatViewFieldMaps: buildFlatViewFieldMaps(), + flatViewMaps: buildFlatViewMaps(), + flatViewFieldGroupMaps: buildFlatViewFieldGroupMaps(), + applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER, + }); + + expect(result).toEqual([]); + }); + + it('should skip widgets whose viewId cannot be resolved', () => { + const result = computeFlatViewFieldsFromFieldsWidgets({ + fieldsToCreate: [ + { + objectMetadataUniversalIdentifier: + OBJECT_METADATA_UNIVERSAL_IDENTIFIER, + fieldMetadataUniversalIdentifier: 'field-uid-1', + }, + ], + flatPageLayoutWidgetMaps: buildFlatPageLayoutWidgetMaps([ + buildFieldsWidget({ viewId: 'non-existent-view-id' }), + ]), + flatViewFieldMaps: buildFlatViewFieldMaps(), + flatViewMaps: buildFlatViewMaps(), + flatViewFieldGroupMaps: buildFlatViewFieldGroupMaps(), + applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER, + }); + + expect(result).toEqual([]); + }); + }); + + describe('when creating view fields for a single field', () => { + it('should create a view field with correct properties', () => { + const result = computeFlatViewFieldsFromFieldsWidgets({ + fieldsToCreate: [ + { + objectMetadataUniversalIdentifier: + OBJECT_METADATA_UNIVERSAL_IDENTIFIER, + fieldMetadataUniversalIdentifier: 'field-uid-1', + }, + ], + flatPageLayoutWidgetMaps: buildFlatPageLayoutWidgetMaps([ + buildFieldsWidget({ isVisible: true }), + ]), + flatViewFieldMaps: buildFlatViewFieldMaps(), + flatViewMaps: buildFlatViewMaps([ + { id: VIEW_ID, universalIdentifier: VIEW_UNIVERSAL_IDENTIFIER }, + ]), + flatViewFieldGroupMaps: buildFlatViewFieldGroupMaps(), + applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER, + }); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER, + fieldMetadataUniversalIdentifier: 'field-uid-1', + viewUniversalIdentifier: VIEW_UNIVERSAL_IDENTIFIER, + viewFieldGroupUniversalIdentifier: null, + isVisible: true, + size: DEFAULT_VIEW_FIELD_SIZE, + position: 0, + aggregateOperation: null, + deletedAt: null, + }); + expect(result[0].universalIdentifier).toBeDefined(); + expect(result[0].createdAt).toBeDefined(); + expect(result[0].updatedAt).toBeDefined(); + }); + + it('should respect isVisible: false from configuration', () => { + const result = computeFlatViewFieldsFromFieldsWidgets({ + fieldsToCreate: [ + { + objectMetadataUniversalIdentifier: + OBJECT_METADATA_UNIVERSAL_IDENTIFIER, + fieldMetadataUniversalIdentifier: 'field-uid-1', + }, + ], + flatPageLayoutWidgetMaps: buildFlatPageLayoutWidgetMaps([ + buildFieldsWidget({ isVisible: false }), + ]), + flatViewFieldMaps: buildFlatViewFieldMaps(), + flatViewMaps: buildFlatViewMaps([ + { id: VIEW_ID, universalIdentifier: VIEW_UNIVERSAL_IDENTIFIER }, + ]), + flatViewFieldGroupMaps: buildFlatViewFieldGroupMaps(), + applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER, + }); + + expect(result).toHaveLength(1); + expect(result[0].isVisible).toBe(false); + }); + }); + + describe('position computation', () => { + it('should start at position 0 when no existing view fields', () => { + const result = computeFlatViewFieldsFromFieldsWidgets({ + fieldsToCreate: [ + { + objectMetadataUniversalIdentifier: + OBJECT_METADATA_UNIVERSAL_IDENTIFIER, + fieldMetadataUniversalIdentifier: 'field-uid-1', + }, + ], + flatPageLayoutWidgetMaps: buildFlatPageLayoutWidgetMaps([ + buildFieldsWidget(), + ]), + flatViewFieldMaps: buildFlatViewFieldMaps(), + flatViewMaps: buildFlatViewMaps([ + { id: VIEW_ID, universalIdentifier: VIEW_UNIVERSAL_IDENTIFIER }, + ]), + flatViewFieldGroupMaps: buildFlatViewFieldGroupMaps(), + applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER, + }); + + expect(result[0].position).toBe(0); + }); + + it('should append after the last existing view field position', () => { + const result = computeFlatViewFieldsFromFieldsWidgets({ + fieldsToCreate: [ + { + objectMetadataUniversalIdentifier: + OBJECT_METADATA_UNIVERSAL_IDENTIFIER, + fieldMetadataUniversalIdentifier: 'field-uid-1', + }, + ], + flatPageLayoutWidgetMaps: buildFlatPageLayoutWidgetMaps([ + buildFieldsWidget(), + ]), + flatViewFieldMaps: buildFlatViewFieldMaps([ + { + universalIdentifier: 'existing-vf-1', + viewId: VIEW_ID, + viewFieldGroupId: null, + position: 3, + deletedAt: null, + }, + { + universalIdentifier: 'existing-vf-2', + viewId: VIEW_ID, + viewFieldGroupId: null, + position: 7, + deletedAt: null, + }, + ]), + flatViewMaps: buildFlatViewMaps([ + { id: VIEW_ID, universalIdentifier: VIEW_UNIVERSAL_IDENTIFIER }, + ]), + flatViewFieldGroupMaps: buildFlatViewFieldGroupMaps(), + applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER, + }); + + expect(result[0].position).toBe(8); + }); + + it('should assign sequential positions for multiple fields in the same batch', () => { + const result = computeFlatViewFieldsFromFieldsWidgets({ + fieldsToCreate: [ + { + objectMetadataUniversalIdentifier: + OBJECT_METADATA_UNIVERSAL_IDENTIFIER, + fieldMetadataUniversalIdentifier: 'field-uid-1', + }, + { + objectMetadataUniversalIdentifier: + OBJECT_METADATA_UNIVERSAL_IDENTIFIER, + fieldMetadataUniversalIdentifier: 'field-uid-2', + }, + { + objectMetadataUniversalIdentifier: + OBJECT_METADATA_UNIVERSAL_IDENTIFIER, + fieldMetadataUniversalIdentifier: 'field-uid-3', + }, + ], + flatPageLayoutWidgetMaps: buildFlatPageLayoutWidgetMaps([ + buildFieldsWidget(), + ]), + flatViewFieldMaps: buildFlatViewFieldMaps([ + { + universalIdentifier: 'existing-vf-1', + viewId: VIEW_ID, + viewFieldGroupId: null, + position: 2, + deletedAt: null, + }, + ]), + flatViewMaps: buildFlatViewMaps([ + { id: VIEW_ID, universalIdentifier: VIEW_UNIVERSAL_IDENTIFIER }, + ]), + flatViewFieldGroupMaps: buildFlatViewFieldGroupMaps(), + applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER, + }); + + expect(result).toHaveLength(3); + expect(result[0].position).toBe(3); + expect(result[1].position).toBe(4); + expect(result[2].position).toBe(5); + }); + + it('should ignore deleted view fields when computing position', () => { + const result = computeFlatViewFieldsFromFieldsWidgets({ + fieldsToCreate: [ + { + objectMetadataUniversalIdentifier: + OBJECT_METADATA_UNIVERSAL_IDENTIFIER, + fieldMetadataUniversalIdentifier: 'field-uid-1', + }, + ], + flatPageLayoutWidgetMaps: buildFlatPageLayoutWidgetMaps([ + buildFieldsWidget(), + ]), + flatViewFieldMaps: buildFlatViewFieldMaps([ + { + universalIdentifier: 'existing-vf-1', + viewId: VIEW_ID, + viewFieldGroupId: null, + position: 5, + deletedAt: null, + }, + { + universalIdentifier: 'deleted-vf', + viewId: VIEW_ID, + viewFieldGroupId: null, + position: 99, + deletedAt: '2024-01-01T00:00:00.000Z', + }, + ]), + flatViewMaps: buildFlatViewMaps([ + { id: VIEW_ID, universalIdentifier: VIEW_UNIVERSAL_IDENTIFIER }, + ]), + flatViewFieldGroupMaps: buildFlatViewFieldGroupMaps(), + applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER, + }); + + expect(result[0].position).toBe(6); + }); + + it('should ignore view fields from a different view', () => { + const result = computeFlatViewFieldsFromFieldsWidgets({ + fieldsToCreate: [ + { + objectMetadataUniversalIdentifier: + OBJECT_METADATA_UNIVERSAL_IDENTIFIER, + fieldMetadataUniversalIdentifier: 'field-uid-1', + }, + ], + flatPageLayoutWidgetMaps: buildFlatPageLayoutWidgetMaps([ + buildFieldsWidget(), + ]), + flatViewFieldMaps: buildFlatViewFieldMaps([ + { + universalIdentifier: 'other-view-vf', + viewId: 'other-view-db-id', + viewFieldGroupId: null, + position: 50, + deletedAt: null, + }, + ]), + flatViewMaps: buildFlatViewMaps([ + { id: VIEW_ID, universalIdentifier: VIEW_UNIVERSAL_IDENTIFIER }, + ]), + flatViewFieldGroupMaps: buildFlatViewFieldGroupMaps(), + applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER, + }); + + expect(result[0].position).toBe(0); + }); + }); + + describe('view field group handling', () => { + it('should resolve viewFieldGroupUniversalIdentifier when viewFieldGroupId is set', () => { + const result = computeFlatViewFieldsFromFieldsWidgets({ + fieldsToCreate: [ + { + objectMetadataUniversalIdentifier: + OBJECT_METADATA_UNIVERSAL_IDENTIFIER, + fieldMetadataUniversalIdentifier: 'field-uid-1', + }, + ], + flatPageLayoutWidgetMaps: buildFlatPageLayoutWidgetMaps([ + buildFieldsWidget({ viewFieldGroupId: VIEW_FIELD_GROUP_ID }), + ]), + flatViewFieldMaps: buildFlatViewFieldMaps(), + flatViewMaps: buildFlatViewMaps([ + { id: VIEW_ID, universalIdentifier: VIEW_UNIVERSAL_IDENTIFIER }, + ]), + flatViewFieldGroupMaps: buildFlatViewFieldGroupMaps([ + { + id: VIEW_FIELD_GROUP_ID, + universalIdentifier: VIEW_FIELD_GROUP_UNIVERSAL_IDENTIFIER, + }, + ]), + applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER, + }); + + expect(result).toHaveLength(1); + expect(result[0].viewFieldGroupUniversalIdentifier).toBe( + VIEW_FIELD_GROUP_UNIVERSAL_IDENTIFIER, + ); + }); + + it('should set viewFieldGroupUniversalIdentifier to null when viewFieldGroupId is null', () => { + const result = computeFlatViewFieldsFromFieldsWidgets({ + fieldsToCreate: [ + { + objectMetadataUniversalIdentifier: + OBJECT_METADATA_UNIVERSAL_IDENTIFIER, + fieldMetadataUniversalIdentifier: 'field-uid-1', + }, + ], + flatPageLayoutWidgetMaps: buildFlatPageLayoutWidgetMaps([ + buildFieldsWidget({ viewFieldGroupId: null }), + ]), + flatViewFieldMaps: buildFlatViewFieldMaps(), + flatViewMaps: buildFlatViewMaps([ + { id: VIEW_ID, universalIdentifier: VIEW_UNIVERSAL_IDENTIFIER }, + ]), + flatViewFieldGroupMaps: buildFlatViewFieldGroupMaps(), + applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER, + }); + + expect(result[0].viewFieldGroupUniversalIdentifier).toBeNull(); + }); + + it('should compute position only from view fields in the same group', () => { + const result = computeFlatViewFieldsFromFieldsWidgets({ + fieldsToCreate: [ + { + objectMetadataUniversalIdentifier: + OBJECT_METADATA_UNIVERSAL_IDENTIFIER, + fieldMetadataUniversalIdentifier: 'field-uid-1', + }, + ], + flatPageLayoutWidgetMaps: buildFlatPageLayoutWidgetMaps([ + buildFieldsWidget({ viewFieldGroupId: VIEW_FIELD_GROUP_ID }), + ]), + flatViewFieldMaps: buildFlatViewFieldMaps([ + { + universalIdentifier: 'vf-in-group', + viewId: VIEW_ID, + viewFieldGroupId: VIEW_FIELD_GROUP_ID, + position: 2, + deletedAt: null, + }, + { + universalIdentifier: 'vf-no-group', + viewId: VIEW_ID, + viewFieldGroupId: null, + position: 99, + deletedAt: null, + }, + { + universalIdentifier: 'vf-other-group', + viewId: VIEW_ID, + viewFieldGroupId: 'other-group-id', + position: 50, + deletedAt: null, + }, + ]), + flatViewMaps: buildFlatViewMaps([ + { id: VIEW_ID, universalIdentifier: VIEW_UNIVERSAL_IDENTIFIER }, + ]), + flatViewFieldGroupMaps: buildFlatViewFieldGroupMaps([ + { + id: VIEW_FIELD_GROUP_ID, + universalIdentifier: VIEW_FIELD_GROUP_UNIVERSAL_IDENTIFIER, + }, + ]), + applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER, + }); + + expect(result[0].position).toBe(3); + }); + + it('should compute position only from ungrouped view fields when viewFieldGroupId is null', () => { + const result = computeFlatViewFieldsFromFieldsWidgets({ + fieldsToCreate: [ + { + objectMetadataUniversalIdentifier: + OBJECT_METADATA_UNIVERSAL_IDENTIFIER, + fieldMetadataUniversalIdentifier: 'field-uid-1', + }, + ], + flatPageLayoutWidgetMaps: buildFlatPageLayoutWidgetMaps([ + buildFieldsWidget({ viewFieldGroupId: null }), + ]), + flatViewFieldMaps: buildFlatViewFieldMaps([ + { + universalIdentifier: 'vf-no-group', + viewId: VIEW_ID, + viewFieldGroupId: null, + position: 1, + deletedAt: null, + }, + { + universalIdentifier: 'vf-in-group', + viewId: VIEW_ID, + viewFieldGroupId: VIEW_FIELD_GROUP_ID, + position: 99, + deletedAt: null, + }, + ]), + flatViewMaps: buildFlatViewMaps([ + { id: VIEW_ID, universalIdentifier: VIEW_UNIVERSAL_IDENTIFIER }, + ]), + flatViewFieldGroupMaps: buildFlatViewFieldGroupMaps(), + applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER, + }); + + expect(result[0].position).toBe(2); + }); + }); + + describe('multiple widgets for the same object', () => { + it('should create view fields for each matching widget', () => { + const result = computeFlatViewFieldsFromFieldsWidgets({ + fieldsToCreate: [ + { + objectMetadataUniversalIdentifier: + OBJECT_METADATA_UNIVERSAL_IDENTIFIER, + fieldMetadataUniversalIdentifier: 'field-uid-1', + }, + ], + flatPageLayoutWidgetMaps: buildFlatPageLayoutWidgetMaps([ + buildFieldsWidget({ + widgetUniversalIdentifier: 'widget-uid-1', + viewId: 'view-db-id-A', + }), + buildFieldsWidget({ + widgetUniversalIdentifier: 'widget-uid-2', + viewId: 'view-db-id-B', + }), + ]), + flatViewFieldMaps: buildFlatViewFieldMaps(), + flatViewMaps: buildFlatViewMaps([ + { id: 'view-db-id-A', universalIdentifier: 'view-uid-A' }, + { id: 'view-db-id-B', universalIdentifier: 'view-uid-B' }, + ]), + flatViewFieldGroupMaps: buildFlatViewFieldGroupMaps(), + applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER, + }); + + expect(result).toHaveLength(2); + expect(result[0].viewUniversalIdentifier).toBe('view-uid-A'); + expect(result[1].viewUniversalIdentifier).toBe('view-uid-B'); + }); + }); + + describe('multiple objects', () => { + it('should create view fields for fields across different objects', () => { + const result = computeFlatViewFieldsFromFieldsWidgets({ + fieldsToCreate: [ + { + objectMetadataUniversalIdentifier: 'obj-uid-1', + fieldMetadataUniversalIdentifier: 'field-uid-1', + }, + { + objectMetadataUniversalIdentifier: 'obj-uid-2', + fieldMetadataUniversalIdentifier: 'field-uid-2', + }, + ], + flatPageLayoutWidgetMaps: buildFlatPageLayoutWidgetMaps([ + buildFieldsWidget({ + widgetUniversalIdentifier: 'widget-uid-1', + objectMetadataUniversalIdentifier: 'obj-uid-1', + viewId: 'view-db-id-A', + }), + buildFieldsWidget({ + widgetUniversalIdentifier: 'widget-uid-2', + objectMetadataUniversalIdentifier: 'obj-uid-2', + viewId: 'view-db-id-B', + }), + ]), + flatViewFieldMaps: buildFlatViewFieldMaps(), + flatViewMaps: buildFlatViewMaps([ + { id: 'view-db-id-A', universalIdentifier: 'view-uid-A' }, + { id: 'view-db-id-B', universalIdentifier: 'view-uid-B' }, + ]), + flatViewFieldGroupMaps: buildFlatViewFieldGroupMaps(), + applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER, + }); + + expect(result).toHaveLength(2); + + const viewFieldForObj1 = result.find( + (viewField) => + viewField.fieldMetadataUniversalIdentifier === 'field-uid-1', + ); + const viewFieldForObj2 = result.find( + (viewField) => + viewField.fieldMetadataUniversalIdentifier === 'field-uid-2', + ); + + expect(viewFieldForObj1?.viewUniversalIdentifier).toBe('view-uid-A'); + expect(viewFieldForObj2?.viewUniversalIdentifier).toBe('view-uid-B'); + }); + }); + + describe('unique universal identifiers', () => { + it('should generate unique universalIdentifier for each created view field', () => { + const result = computeFlatViewFieldsFromFieldsWidgets({ + fieldsToCreate: [ + { + objectMetadataUniversalIdentifier: + OBJECT_METADATA_UNIVERSAL_IDENTIFIER, + fieldMetadataUniversalIdentifier: 'field-uid-1', + }, + { + objectMetadataUniversalIdentifier: + OBJECT_METADATA_UNIVERSAL_IDENTIFIER, + fieldMetadataUniversalIdentifier: 'field-uid-2', + }, + ], + flatPageLayoutWidgetMaps: buildFlatPageLayoutWidgetMaps([ + buildFieldsWidget(), + ]), + flatViewFieldMaps: buildFlatViewFieldMaps(), + flatViewMaps: buildFlatViewMaps([ + { id: VIEW_ID, universalIdentifier: VIEW_UNIVERSAL_IDENTIFIER }, + ]), + flatViewFieldGroupMaps: buildFlatViewFieldGroupMaps(), + applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER, + }); + + const universalIdentifiers = result.map( + (viewField) => viewField.universalIdentifier, + ); + + expect(new Set(universalIdentifiers).size).toBe( + universalIdentifiers.length, + ); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-view-field/utils/compute-flat-view-fields-from-fields-widgets.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-view-field/utils/compute-flat-view-fields-from-fields-widgets.util.ts new file mode 100644 index 0000000000..aa51289978 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-view-field/utils/compute-flat-view-fields-from-fields-widgets.util.ts @@ -0,0 +1,183 @@ +import { isDefined } from 'twenty-shared/utils'; +import { v4 } from 'uuid'; + +import { type FlatPageLayoutWidgetMaps } from 'src/engine/metadata-modules/flat-page-layout-widget/types/flat-page-layout-widget-maps.type'; +import { type FlatPageLayoutWidget } from 'src/engine/metadata-modules/flat-page-layout-widget/types/flat-page-layout-widget.type'; +import { DEFAULT_VIEW_FIELD_SIZE } from 'src/engine/metadata-modules/flat-view-field/constants/default-view-field-size.constant'; +import { type FlatViewFieldMaps } from 'src/engine/metadata-modules/flat-view-field/types/flat-view-field-maps.type'; +import { type FlatViewField } from 'src/engine/metadata-modules/flat-view-field/types/flat-view-field.type'; +import { type FlatViewFieldGroupMaps } from 'src/engine/metadata-modules/flat-view-field-group/types/flat-view-field-group-maps.type'; +import { type FlatViewMaps } from 'src/engine/metadata-modules/flat-view/types/flat-view-maps.type'; +import { type FieldsConfigurationDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/fields-configuration.dto'; +import { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-configuration-type.type'; +import { WidgetType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-type.enum'; +import { type AllPageLayoutWidgetConfiguration } from 'src/engine/metadata-modules/page-layout-widget/types/all-page-layout-widget-configuration.type'; +import { type UniversalFlatViewField } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-view-field.type'; + +type FieldToCreateInfo = { + objectMetadataUniversalIdentifier: string; + fieldMetadataUniversalIdentifier: string; +}; + +const isFieldsWidgetConfiguration = ( + configuration: AllPageLayoutWidgetConfiguration, +): configuration is FieldsConfigurationDTO => { + return ( + isDefined(configuration) && + configuration.configurationType === WidgetConfigurationType.FIELDS + ); +}; + +const getMatchingFieldsWidgets = ({ + objectMetadataUniversalIdentifier, + flatPageLayoutWidgetMaps, +}: { + objectMetadataUniversalIdentifier: string; + flatPageLayoutWidgetMaps: FlatPageLayoutWidgetMaps; +}): FlatPageLayoutWidget[] => + Object.values(flatPageLayoutWidgetMaps.byUniversalIdentifier) + .filter(isDefined) + .filter( + (widget) => + !isDefined(widget.deletedAt) && + widget.type === WidgetType.FIELDS && + widget.objectMetadataUniversalIdentifier === + objectMetadataUniversalIdentifier && + isFieldsWidgetConfiguration(widget.configuration) && + isDefined(widget.configuration.viewId) && + isDefined(widget.configuration.newFieldDefaultConfiguration), + ); + +const computeNextPosition = ({ + viewId, + viewFieldGroupId, + flatViewFieldMaps, +}: { + viewId: string; + viewFieldGroupId: string | null; + flatViewFieldMaps: FlatViewFieldMaps; +}): number => { + const existingViewFields = Object.values( + flatViewFieldMaps.byUniversalIdentifier, + ) + .filter(isDefined) + .filter( + (viewField: FlatViewField) => + !isDefined(viewField.deletedAt) && viewField.viewId === viewId, + ) + .filter( + (viewField: FlatViewField) => + viewField.viewFieldGroupId === viewFieldGroupId, + ); + + if (existingViewFields.length === 0) { + return 0; + } + + const maxPosition = Math.max( + ...existingViewFields.map((viewField) => viewField.position), + ); + + return maxPosition + 1; +}; + +export const computeFlatViewFieldsFromFieldsWidgets = ({ + fieldsToCreate, + flatPageLayoutWidgetMaps, + flatViewFieldMaps, + flatViewMaps, + flatViewFieldGroupMaps, + applicationUniversalIdentifier, +}: { + fieldsToCreate: FieldToCreateInfo[]; + flatPageLayoutWidgetMaps: FlatPageLayoutWidgetMaps; + flatViewFieldMaps: FlatViewFieldMaps; + flatViewMaps: FlatViewMaps; + flatViewFieldGroupMaps: FlatViewFieldGroupMaps; + applicationUniversalIdentifier: string; +}): UniversalFlatViewField[] => { + const flatViewFieldsToCreate: UniversalFlatViewField[] = []; + const now = new Date().toISOString(); + + const objectMetadataUniversalIdentifiers = [ + ...new Set( + fieldsToCreate.map((field) => field.objectMetadataUniversalIdentifier), + ), + ]; + + const nextPositionByKey = new Map(); + + for (const objectMetadataUniversalIdentifier of objectMetadataUniversalIdentifiers) { + const matchingWidgets = getMatchingFieldsWidgets({ + objectMetadataUniversalIdentifier, + flatPageLayoutWidgetMaps, + }); + + const fieldsForObject = fieldsToCreate.filter( + (field) => + field.objectMetadataUniversalIdentifier === + objectMetadataUniversalIdentifier, + ); + + for (const widget of matchingWidgets) { + if (!isFieldsWidgetConfiguration(widget.configuration)) { + continue; + } + + const configuration = widget.configuration; + + const viewId = configuration.viewId!; + const { isVisible, viewFieldGroupId } = + configuration.newFieldDefaultConfiguration!; + + const viewUniversalIdentifier = + flatViewMaps.universalIdentifierById[viewId] ?? null; + + if (!isDefined(viewUniversalIdentifier)) { + continue; + } + + const viewFieldGroupUniversalIdentifier = isDefined(viewFieldGroupId) + ? (flatViewFieldGroupMaps.universalIdentifierById[viewFieldGroupId] ?? + null) + : null; + + const positionKey = `${viewId}:${viewFieldGroupId ?? 'null'}`; + + if (!nextPositionByKey.has(positionKey)) { + nextPositionByKey.set( + positionKey, + computeNextPosition({ + viewId, + viewFieldGroupId, + flatViewFieldMaps, + }), + ); + } + + for (const field of fieldsForObject) { + const position = nextPositionByKey.get(positionKey)!; + + nextPositionByKey.set(positionKey, position + 1); + + flatViewFieldsToCreate.push({ + universalIdentifier: v4(), + applicationUniversalIdentifier, + fieldMetadataUniversalIdentifier: + field.fieldMetadataUniversalIdentifier, + viewUniversalIdentifier, + viewFieldGroupUniversalIdentifier, + isVisible, + size: DEFAULT_VIEW_FIELD_SIZE, + position, + aggregateOperation: null, + createdAt: now, + updatedAt: now, + deletedAt: null, + }); + } + } + } + + return flatViewFieldsToCreate; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts index 0b1598c841..3499d92051 100644 --- a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts @@ -2,14 +2,14 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm'; -import { fromArrayToUniqueKeyRecord, isDefined } from 'twenty-shared/utils'; -import { FindManyOptions, FindOneOptions, Repository } from 'typeorm'; -import { v4 as uuidv4, v4 } from 'uuid'; import { ViewOpenRecordIn, ViewType, ViewVisibility, } from 'twenty-shared/types'; +import { fromArrayToUniqueKeyRecord, isDefined } from 'twenty-shared/utils'; +import { FindManyOptions, FindOneOptions, Repository } from 'typeorm'; +import { v4 as uuidv4, v4 } from 'uuid'; import { ApplicationService } from 'src/engine/core-modules/application/services/application.service'; import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type'; @@ -443,7 +443,10 @@ export class ObjectMetadataService extends TypeOrmQueryService Boolean) + @IsBoolean() + isVisible: boolean; + + @Field(() => String, { nullable: true }) + @IsOptional() + @IsUUID() + viewFieldGroupId: string | null; +} + @ObjectType('FieldsConfiguration') export class FieldsConfigurationDTO implements PageLayoutWidgetConfigurationBase @@ -18,4 +38,10 @@ export class FieldsConfigurationDTO @IsOptional() @IsUUID() viewId: string | null; + + @Field(() => NewFieldDefaultConfigurationDTO, { nullable: true }) + @IsOptional() + @ValidateNested() + @Type(() => NewFieldDefaultConfigurationDTO) + newFieldDefaultConfiguration: NewFieldDefaultConfigurationDTO | null; } diff --git a/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/services/page-layout-widget.service.ts b/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/services/page-layout-widget.service.ts index 9dd2c09ab0..5fa90bed71 100644 --- a/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/services/page-layout-widget.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/services/page-layout-widget.service.ts @@ -243,6 +243,7 @@ export class PageLayoutWidgetService { flatPageLayoutTabMaps, flatObjectMetadataMaps, flatFieldMetadataMaps, + flatViewFieldGroupMaps, } = await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( { @@ -251,6 +252,7 @@ export class PageLayoutWidgetService { 'flatPageLayoutTabMaps', 'flatObjectMetadataMaps', 'flatFieldMetadataMaps', + 'flatViewFieldGroupMaps', ], }, ); @@ -263,6 +265,7 @@ export class PageLayoutWidgetService { flatPageLayoutTabMaps, flatObjectMetadataMaps, flatFieldMetadataMaps, + flatViewFieldGroupMaps, }); if (isDefined(createInput.configuration)) { @@ -321,13 +324,17 @@ export class PageLayoutWidgetService { const { flatObjectMetadataMaps: existingFlatObjectMetadataMaps, flatFieldMetadataMaps: existingFlatFieldMetadataMaps, - } = - await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( - { - workspaceId, - flatMapsKeys: ['flatObjectMetadataMaps', 'flatFieldMetadataMaps'], - }, - ); + flatViewFieldGroupMaps: existingFlatViewFieldGroupMaps, + } = await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( + { + workspaceId, + flatMapsKeys: [ + 'flatObjectMetadataMaps', + 'flatFieldMetadataMaps', + 'flatViewFieldGroupMaps', + ], + }, + ); const isConfigurationBeingUpdated = Object.prototype.hasOwnProperty.call( updateData, @@ -357,6 +364,7 @@ export class PageLayoutWidgetService { flatPageLayoutWidgetMaps: existingFlatPageLayoutWidgetMaps, flatObjectMetadataMaps: existingFlatObjectMetadataMaps, flatFieldMetadataMaps: existingFlatFieldMetadataMaps, + flatViewFieldGroupMaps: existingFlatViewFieldGroupMaps, }); const shouldValidateChartFields = diff --git a/packages/twenty-server/src/engine/metadata-modules/page-layout/services/page-layout-duplication.service.ts b/packages/twenty-server/src/engine/metadata-modules/page-layout/services/page-layout-duplication.service.ts index 22e77d1bb2..64e94ffe0e 100644 --- a/packages/twenty-server/src/engine/metadata-modules/page-layout/services/page-layout-duplication.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/page-layout/services/page-layout-duplication.service.ts @@ -52,6 +52,7 @@ export class PageLayoutDuplicationService { flatPageLayoutWidgetMaps, flatObjectMetadataMaps, flatFieldMetadataMaps, + flatViewFieldGroupMaps, } = await this.getPageLayoutFlatEntityMaps(workspaceId); const originalFlatLayout = this.findOriginalLayoutOrThrow( @@ -114,6 +115,7 @@ export class PageLayoutDuplicationService { flatPageLayoutTabMaps: optimisticFlatPageLayoutTabMaps, flatObjectMetadataMaps, flatFieldMetadataMaps, + flatViewFieldGroupMaps, }); const validateAndBuildResult = @@ -181,6 +183,7 @@ export class PageLayoutDuplicationService { 'flatPageLayoutWidgetMaps', 'flatObjectMetadataMaps', 'flatFieldMetadataMaps', + 'flatViewFieldGroupMaps', ], }, ); @@ -290,6 +293,7 @@ export class PageLayoutDuplicationService { flatPageLayoutTabMaps, flatObjectMetadataMaps, flatFieldMetadataMaps, + flatViewFieldGroupMaps, }: { originalTabsWithWidgets: { tab: FlatPageLayoutTab; @@ -301,6 +305,7 @@ export class PageLayoutDuplicationService { flatPageLayoutTabMaps: AllFlatEntityMaps['flatPageLayoutTabMaps']; flatObjectMetadataMaps: AllFlatEntityMaps['flatObjectMetadataMaps']; flatFieldMetadataMaps: AllFlatEntityMaps['flatFieldMetadataMaps']; + flatViewFieldGroupMaps: AllFlatEntityMaps['flatViewFieldGroupMaps']; }): FlatPageLayoutWidget[] { return originalTabsWithWidgets.flatMap(({ tab, widgets }) => { const newTabId = originalTabIdToNewTabIdMap.get(tab.id)!; @@ -320,6 +325,7 @@ export class PageLayoutDuplicationService { flatPageLayoutTabMaps, flatObjectMetadataMaps, flatFieldMetadataMaps, + flatViewFieldGroupMaps, }), ); }); diff --git a/packages/twenty-server/src/engine/metadata-modules/page-layout/services/page-layout-update.service.ts b/packages/twenty-server/src/engine/metadata-modules/page-layout/services/page-layout-update.service.ts index fa583964f9..fe88fe43d3 100644 --- a/packages/twenty-server/src/engine/metadata-modules/page-layout/services/page-layout-update.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/page-layout/services/page-layout-update.service.ts @@ -116,11 +116,19 @@ export class PageLayoutUpdateService { workspaceCustomFlatApplication.universalIdentifier, }); - const { flatObjectMetadataMaps, flatFieldMetadataMaps } = + const { + flatObjectMetadataMaps, + flatFieldMetadataMaps, + flatViewFieldGroupMaps, + } = await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( { workspaceId, - flatMapsKeys: ['flatObjectMetadataMaps', 'flatFieldMetadataMaps'], + flatMapsKeys: [ + 'flatObjectMetadataMaps', + 'flatFieldMetadataMaps', + 'flatViewFieldGroupMaps', + ], }, ); @@ -144,6 +152,7 @@ export class PageLayoutUpdateService { workspaceCustomApplicationUniversalIdentifier: workspaceCustomFlatApplication.universalIdentifier, flatFieldMetadataMaps, + flatViewFieldGroupMaps, }); const validateAndBuildResult = @@ -355,6 +364,7 @@ export class PageLayoutUpdateService { workspaceCustomApplicationId, workspaceCustomApplicationUniversalIdentifier, flatFieldMetadataMaps, + flatViewFieldGroupMaps, }: { tabs: UpdatePageLayoutTabWithWidgetsInput[]; workspaceId: string; @@ -364,6 +374,7 @@ export class PageLayoutUpdateService { AllFlatEntityMaps, | 'flatObjectMetadataMaps' | 'flatFieldMetadataMaps' + | 'flatViewFieldGroupMaps' | 'flatPageLayoutTabMaps' | 'flatPageLayoutWidgetMaps' >): { @@ -386,6 +397,7 @@ export class PageLayoutUpdateService { workspaceCustomApplicationId, workspaceCustomApplicationUniversalIdentifier, flatFieldMetadataMaps, + flatViewFieldGroupMaps, }); allWidgetsToCreate.push(...widgetsToCreate); @@ -409,6 +421,7 @@ export class PageLayoutUpdateService { workspaceCustomApplicationId, workspaceCustomApplicationUniversalIdentifier, flatFieldMetadataMaps, + flatViewFieldGroupMaps, }: { tabId: string; widgets: UpdatePageLayoutWidgetWithIdInput[]; @@ -419,6 +432,7 @@ export class PageLayoutUpdateService { AllFlatEntityMaps, | 'flatObjectMetadataMaps' | 'flatFieldMetadataMaps' + | 'flatViewFieldGroupMaps' | 'flatPageLayoutTabMaps' | 'flatPageLayoutWidgetMaps' >): { @@ -487,6 +501,8 @@ export class PageLayoutUpdateService { configuration: widgetInput.configuration, fieldMetadataUniversalIdentifierById: flatFieldMetadataMaps.universalIdentifierById, + viewFieldGroupUniversalIdentifierById: + flatViewFieldGroupMaps.universalIdentifierById, shouldThrowOnMissingIdentifier: true, }), }; @@ -532,6 +548,8 @@ export class PageLayoutUpdateService { configuration: updatedConfiguration, fieldMetadataUniversalIdentifierById: flatFieldMetadataMaps.universalIdentifierById, + viewFieldGroupUniversalIdentifierById: + flatViewFieldGroupMaps.universalIdentifierById, }), }), }; @@ -578,6 +596,8 @@ export class PageLayoutUpdateService { configuration: restoredConfiguration, fieldMetadataUniversalIdentifierById: flatFieldMetadataMaps.universalIdentifierById, + viewFieldGroupUniversalIdentifierById: + flatViewFieldGroupMaps.universalIdentifierById, }), }), }; diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts index db7b2c0c70..1bb9d1cbc4 100644 --- a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts @@ -101,6 +101,11 @@ export const seedFeatureFlags = async ({ workspaceId: workspaceId, value: true, }, + { + key: FeatureFlagKey.IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED, + workspaceId: workspaceId, + value: true, + }, ]) .execute(); }; diff --git a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/page-layout-widget/build-standard-flat-page-layout-widget-metadata-maps.util.ts b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/page-layout-widget/build-standard-flat-page-layout-widget-metadata-maps.util.ts index 0a9a813d54..f2e5930056 100644 --- a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/page-layout-widget/build-standard-flat-page-layout-widget-metadata-maps.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/page-layout-widget/build-standard-flat-page-layout-widget-metadata-maps.util.ts @@ -128,34 +128,71 @@ const buildFieldsWidgetConfiguration = ({ configuration: { configurationType: WidgetConfigurationType.FIELDS, viewId: null, + newFieldDefaultConfiguration: { + isVisible: true, + viewFieldGroupId: null, + }, }, universalConfiguration: { configurationType: WidgetConfigurationType.FIELDS, viewId: null, + newFieldDefaultConfiguration: { + isVisible: true, + viewFieldGroupId: null, + }, }, }; } const views = standardObjectMetadataRelatedEntityIds[objectName] - .views as Record; + .views as Record< + string, + { + id: string; + viewFieldGroups?: Record; + } + >; const viewId = views[recordPageFieldsViewName]?.id ?? null; // @ts-expect-error ignore const viewDefinition = STANDARD_OBJECTS[objectName].views?.[ recordPageFieldsViewName - ] as { universalIdentifier: string } | undefined; + ] as + | { + universalIdentifier: string; + viewFieldGroups?: Record; + } + | undefined; const viewUniversalIdentifier = viewDefinition?.universalIdentifier ?? null; + const otherViewFieldGroupId = + views[recordPageFieldsViewName]?.viewFieldGroups?.other?.id ?? null; + + const otherViewFieldGroupUniversalIdentifier = + viewDefinition?.viewFieldGroups?.other?.universalIdentifier ?? null; + + const newFieldDefaultConfiguration = { + isVisible: true, + viewFieldGroupId: otherViewFieldGroupId, + }; + + const universalNewFieldDefaultConfiguration = { + isVisible: true, + viewFieldGroupId: otherViewFieldGroupUniversalIdentifier, + }; + return { configuration: { configurationType: WidgetConfigurationType.FIELDS, viewId, + newFieldDefaultConfiguration, }, universalConfiguration: { configurationType: WidgetConfigurationType.FIELDS, viewId: viewUniversalIdentifier, + newFieldDefaultConfiguration: universalNewFieldDefaultConfiguration, }, }; }; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/page-layout-widget/services/create-page-layout-widget-action-handler.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/page-layout-widget/services/create-page-layout-widget-action-handler.service.ts index cfd765186d..0a8edb372d 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/page-layout-widget/services/create-page-layout-widget-action-handler.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/page-layout-widget/services/create-page-layout-widget-action-handler.service.ts @@ -43,6 +43,7 @@ export class CreatePageLayoutWidgetActionHandlerService extends WorkspaceMigrati universalConfiguration: action.flatEntity.universalConfiguration, flatFieldMetadataMaps: allFlatEntityMaps.flatFieldMetadataMaps, flatViewMaps: allFlatEntityMaps.flatViewMaps, + flatViewFieldGroupMaps: allFlatEntityMaps.flatViewFieldGroupMaps, }); const emptyUniversalForeignKeyAggregators = diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/page-layout-widget/services/update-page-layout-widget-action-handler.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/page-layout-widget/services/update-page-layout-widget-action-handler.service.ts index 288a4a751b..c52201fb74 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/page-layout-widget/services/update-page-layout-widget-action-handler.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/page-layout-widget/services/update-page-layout-widget-action-handler.service.ts @@ -51,6 +51,8 @@ export class UpdatePageLayoutWidgetActionHandlerService extends WorkspaceMigrati universalConfiguration, flatFieldMetadataMaps: allFlatEntityMaps.flatFieldMetadataMaps, flatViewMaps: allFlatEntityMaps.flatViewMaps, + flatViewFieldGroupMaps: + allFlatEntityMaps.flatViewFieldGroupMaps, }), }; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/page-layout-widget/services/utils/from-universal-configuration-to-flat-page-layout-widget-configuration.util.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/page-layout-widget/services/utils/from-universal-configuration-to-flat-page-layout-widget-configuration.util.ts index 3be33337cf..599359832c 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/page-layout-widget/services/utils/from-universal-configuration-to-flat-page-layout-widget-configuration.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/page-layout-widget/services/utils/from-universal-configuration-to-flat-page-layout-widget-configuration.util.ts @@ -42,10 +42,12 @@ export const fromUniversalConfigurationToFlatPageLayoutWidgetConfiguration = ({ universalConfiguration, flatFieldMetadataMaps, flatViewMaps, + flatViewFieldGroupMaps, }: { universalConfiguration: FlatPageLayoutWidget['universalConfiguration']; flatFieldMetadataMaps: MetadataFlatEntityMaps<'fieldMetadata'>; flatViewMaps: MetadataFlatEntityMaps<'view'>; + flatViewFieldGroupMaps: MetadataFlatEntityMaps<'viewFieldGroup'>; }): FlatPageLayoutWidget['configuration'] => { switch (universalConfiguration.configurationType) { case WidgetConfigurationType.AGGREGATE_CHART: { @@ -198,26 +200,61 @@ export const fromUniversalConfigurationToFlatPageLayoutWidgetConfiguration = ({ } case WidgetConfigurationType.FIELDS: { - const { viewId: viewUniversalIdentifier, ...rest } = - universalConfiguration; + const { + viewId: viewUniversalIdentifier, + newFieldDefaultConfiguration: universalNewFieldDefaultConfiguration, + ...rest + } = universalConfiguration; - if (!isDefined(viewUniversalIdentifier)) { - return { ...rest, viewId: null }; + let viewId: string | null = null; + + if (isDefined(viewUniversalIdentifier)) { + const flatView = findFlatEntityByUniversalIdentifier({ + flatEntityMaps: flatViewMaps, + universalIdentifier: viewUniversalIdentifier, + }); + + if (!isDefined(flatView)) { + throw new FlatEntityMapsException( + `View not found for universal identifier: ${viewUniversalIdentifier}`, + FlatEntityMapsExceptionCode.ENTITY_NOT_FOUND, + ); + } + + viewId = flatView.id; } - const flatView = findFlatEntityByUniversalIdentifier({ - flatEntityMaps: flatViewMaps, - universalIdentifier: viewUniversalIdentifier, - }); + let newFieldDefaultConfiguration: + | { isVisible: boolean; viewFieldGroupId: string | null } + | null + | undefined = universalNewFieldDefaultConfiguration; - if (!isDefined(flatView)) { - throw new FlatEntityMapsException( - `View not found for universal identifier: ${viewUniversalIdentifier}`, - FlatEntityMapsExceptionCode.ENTITY_NOT_FOUND, - ); + if ( + isDefined(universalNewFieldDefaultConfiguration) && + isDefined(universalNewFieldDefaultConfiguration.viewFieldGroupId) + ) { + const viewFieldGroupUniversalIdentifier = + universalNewFieldDefaultConfiguration.viewFieldGroupId; + + const flatViewFieldGroup = findFlatEntityByUniversalIdentifier({ + flatEntityMaps: flatViewFieldGroupMaps, + universalIdentifier: viewFieldGroupUniversalIdentifier, + }); + + if (!isDefined(flatViewFieldGroup)) { + throw new FlatEntityMapsException( + `View field group not found for universal identifier: ${viewFieldGroupUniversalIdentifier}`, + FlatEntityMapsExceptionCode.ENTITY_NOT_FOUND, + ); + } + + newFieldDefaultConfiguration = { + isVisible: universalNewFieldDefaultConfiguration.isVisible, + viewFieldGroupId: flatViewFieldGroup.id, + }; } - return { ...rest, viewId: flatView.id }; + return { ...rest, viewId, newFieldDefaultConfiguration }; } case WidgetConfigurationType.VIEW: