From 8dd43be9a2c5780ef94960ff7fbb97dbc8f07a3d Mon Sep 17 00:00:00 2001 From: Paul Rastoin <45004772+prastoin@users.noreply.github.com> Date: Tue, 4 Nov 2025 15:17:55 +0100 Subject: [PATCH] Create many view groups (#15591) # Introduction Same as https://github.com/twentyhq/twenty/pull/15576 but for view groups creation When creating a kanban view with v2 flag activated in production result in race condition due to request being slow and //. That's why we're introducing a batch create on view group here closing https://github.com/twentyhq/core-team-issues/issues/1847 ## In v2 - batch create view group endpoint is available - frontend will target the new endpoint ## In v1 - batch create view group endpoint is not available - frontend will stick to old fake batch view group creation loop ## Gallery ### v2 image ### v1 image --- packages/twenty-front/jest.config.mjs | 2 +- .../src/generated-metadata/graphql.ts | 46 +++ .../twenty-front/src/generated/graphql.ts | 46 +++ .../useSetViewTypeFromLayoutOptionsMenu.ts | 12 +- .../hooks/useHandleRecordGroupField.ts | 12 +- .../mutations/createManyCoreViewGroups.ts | 11 + .../hooks/internal/usePersistViewGroup.ts | 86 +++++- .../hooks/useCreateViewFromCurrentView.ts | 12 +- .../views/hooks/useSaveCurrentViewGroups.ts | 12 +- .../resolvers/view-group.resolver.ts | 29 ++ .../services/view-group-v2.service.ts | 51 +++- ...ny-view-groups-v2.integration-spec.ts.snap | 94 +++++++ ...te-many-view-groups-v2.integration-spec.ts | 152 ++++++++++ ...te-many-view-groups-v2.integration-spec.ts | 265 ++++++++++++++++++ ...any-core-view-groups-query-factory.util.ts | 23 ++ .../create-many-core-view-groups.util.ts | 43 +++ .../view-group.integration-spec.ts.snap | 2 +- 17 files changed, 857 insertions(+), 41 deletions(-) create mode 100644 packages/twenty-front/src/modules/views/graphql/mutations/createManyCoreViewGroups.ts create mode 100644 packages/twenty-server/test/integration/graphql/suites/view/view-group/__snapshots__/failing-create-many-view-groups-v2.integration-spec.ts.snap create mode 100644 packages/twenty-server/test/integration/graphql/suites/view/view-group/failing-create-many-view-groups-v2.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/graphql/suites/view/view-group/successful-create-many-view-groups-v2.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/view-group/utils/create-many-core-view-groups-query-factory.util.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/view-group/utils/create-many-core-view-groups.util.ts diff --git a/packages/twenty-front/jest.config.mjs b/packages/twenty-front/jest.config.mjs index 48a251a68e..e37fd1dde0 100644 --- a/packages/twenty-front/jest.config.mjs +++ b/packages/twenty-front/jest.config.mjs @@ -63,7 +63,7 @@ const jestConfig = { global: { statements: 52, // Temporarily decreasing to 50.97 as introduced v1 code that aims to be deleted - lines: 50.97, + lines: 50.95, functions: 41, }, }, diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 98d130d97d..535c59768f 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -1758,6 +1758,7 @@ export type Mutation = { createEmailingDomain: EmailingDomain; createFile: File; createManyCoreViewFields: Array; + createManyCoreViewGroups: Array; createOIDCIdentityProvider: SetupSsoOutput; createObjectEvent: Analytics; createOneAgent: Agent; @@ -2036,6 +2037,11 @@ export type MutationCreateManyCoreViewFieldsArgs = { }; +export type MutationCreateManyCoreViewGroupsArgs = { + inputs: Array; +}; + + export type MutationCreateOidcIdentityProviderArgs = { input: SetupOidcSsoInput; }; @@ -5979,6 +5985,13 @@ export type CreateManyCoreViewFieldsMutationVariables = Exact<{ export type CreateManyCoreViewFieldsMutation = { __typename?: 'Mutation', createManyCoreViewFields: Array<{ __typename?: 'CoreViewField', id: string, fieldMetadataId: string, viewId: string, isVisible: boolean, position: number, size: number, aggregateOperation?: AggregateOperations | null, createdAt: string, updatedAt: string, deletedAt?: string | null }> }; +export type CreateManyCoreViewGroupsMutationVariables = Exact<{ + inputs: Array | CreateViewGroupInput; +}>; + + +export type CreateManyCoreViewGroupsMutation = { __typename?: 'Mutation', createManyCoreViewGroups: Array<{ __typename?: 'CoreViewGroup', id: string, fieldMetadataId: string, isVisible: boolean, fieldValue: string, position: number, viewId: string, createdAt: string, updatedAt: string, deletedAt?: string | null }> }; + export type DeleteCoreViewMutationVariables = Exact<{ id: Scalars['String']; }>; @@ -12668,6 +12681,39 @@ export function useCreateManyCoreViewFieldsMutation(baseOptions?: Apollo.Mutatio export type CreateManyCoreViewFieldsMutationHookResult = ReturnType; export type CreateManyCoreViewFieldsMutationResult = Apollo.MutationResult; export type CreateManyCoreViewFieldsMutationOptions = Apollo.BaseMutationOptions; +export const CreateManyCoreViewGroupsDocument = gql` + mutation CreateManyCoreViewGroups($inputs: [CreateViewGroupInput!]!) { + createManyCoreViewGroups(inputs: $inputs) { + ...ViewGroupFragment + } +} + ${ViewGroupFragmentFragmentDoc}`; +export type CreateManyCoreViewGroupsMutationFn = Apollo.MutationFunction; + +/** + * __useCreateManyCoreViewGroupsMutation__ + * + * To run a mutation, you first call `useCreateManyCoreViewGroupsMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useCreateManyCoreViewGroupsMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [createManyCoreViewGroupsMutation, { data, loading, error }] = useCreateManyCoreViewGroupsMutation({ + * variables: { + * inputs: // value for 'inputs' + * }, + * }); + */ +export function useCreateManyCoreViewGroupsMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(CreateManyCoreViewGroupsDocument, options); + } +export type CreateManyCoreViewGroupsMutationHookResult = ReturnType; +export type CreateManyCoreViewGroupsMutationResult = Apollo.MutationResult; +export type CreateManyCoreViewGroupsMutationOptions = Apollo.BaseMutationOptions; export const DeleteCoreViewDocument = gql` mutation DeleteCoreView($id: String!) { deleteCoreView(id: $id) diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index 148fdffe15..9cbd9a2caf 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -1715,6 +1715,7 @@ export type Mutation = { createEmailingDomain: EmailingDomain; createFile: File; createManyCoreViewFields: Array; + createManyCoreViewGroups: Array; createOIDCIdentityProvider: SetupSsoOutput; createObjectEvent: Analytics; createOneAgent: Agent; @@ -1987,6 +1988,11 @@ export type MutationCreateManyCoreViewFieldsArgs = { }; +export type MutationCreateManyCoreViewGroupsArgs = { + inputs: Array; +}; + + export type MutationCreateOidcIdentityProviderArgs = { input: SetupOidcSsoInput; }; @@ -4789,6 +4795,13 @@ export type CreateManyCoreViewFieldsMutationVariables = Exact<{ export type CreateManyCoreViewFieldsMutation = { __typename?: 'Mutation', createManyCoreViewFields: Array<{ __typename?: 'CoreViewField', id: any, fieldMetadataId: any, viewId: any, isVisible: boolean, position: number, size: number, aggregateOperation?: AggregateOperations | null, createdAt: string, updatedAt: string, deletedAt?: string | null }> }; +export type CreateManyCoreViewGroupsMutationVariables = Exact<{ + inputs: Array | CreateViewGroupInput; +}>; + + +export type CreateManyCoreViewGroupsMutation = { __typename?: 'Mutation', createManyCoreViewGroups: Array<{ __typename?: 'CoreViewGroup', id: any, fieldMetadataId: any, isVisible: boolean, fieldValue: string, position: number, viewId: any, createdAt: string, updatedAt: string, deletedAt?: string | null }> }; + export type DeleteCoreViewMutationVariables = Exact<{ id: Scalars['String']; }>; @@ -5588,6 +5601,39 @@ export function useCreateManyCoreViewFieldsMutation(baseOptions?: Apollo.Mutatio export type CreateManyCoreViewFieldsMutationHookResult = ReturnType; export type CreateManyCoreViewFieldsMutationResult = Apollo.MutationResult; export type CreateManyCoreViewFieldsMutationOptions = Apollo.BaseMutationOptions; +export const CreateManyCoreViewGroupsDocument = gql` + mutation CreateManyCoreViewGroups($inputs: [CreateViewGroupInput!]!) { + createManyCoreViewGroups(inputs: $inputs) { + ...ViewGroupFragment + } +} + ${ViewGroupFragmentFragmentDoc}`; +export type CreateManyCoreViewGroupsMutationFn = Apollo.MutationFunction; + +/** + * __useCreateManyCoreViewGroupsMutation__ + * + * To run a mutation, you first call `useCreateManyCoreViewGroupsMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useCreateManyCoreViewGroupsMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [createManyCoreViewGroupsMutation, { data, loading, error }] = useCreateManyCoreViewGroupsMutation({ + * variables: { + * inputs: // value for 'inputs' + * }, + * }); + */ +export function useCreateManyCoreViewGroupsMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(CreateManyCoreViewGroupsDocument, options); + } +export type CreateManyCoreViewGroupsMutationHookResult = ReturnType; +export type CreateManyCoreViewGroupsMutationResult = Apollo.MutationResult; +export type CreateManyCoreViewGroupsMutationOptions = Apollo.BaseMutationOptions; export const DeleteCoreViewDocument = gql` mutation DeleteCoreView($id: String!) { deleteCoreView(id: $id) diff --git a/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/useSetViewTypeFromLayoutOptionsMenu.ts b/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/useSetViewTypeFromLayoutOptionsMenu.ts index 835ba24afa..4cee83a213 100644 --- a/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/useSetViewTypeFromLayoutOptionsMenu.ts +++ b/packages/twenty-front/src/modules/object-record/object-options-dropdown/hooks/useSetViewTypeFromLayoutOptionsMenu.ts @@ -58,14 +58,12 @@ export const useSetViewTypeFromLayoutOptionsMenu = () => { fieldMetadataId: randomFieldForKanban, } satisfies ViewGroup); - await createViewGroups( - viewGroupsToCreate.map(({ __typename, ...viewGroup }) => ({ - input: { - ...viewGroup, - viewId: currentViewId, - }, + await createViewGroups({ + inputs: viewGroupsToCreate.map(({ __typename, ...viewGroup }) => ({ + ...viewGroup, + viewId: currentViewId, })), - ); + }); return viewGroupsToCreate; }, diff --git a/packages/twenty-front/src/modules/object-record/record-index/hooks/useHandleRecordGroupField.ts b/packages/twenty-front/src/modules/object-record/record-index/hooks/useHandleRecordGroupField.ts index 4f59f67fc9..6c510530a2 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/hooks/useHandleRecordGroupField.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/hooks/useHandleRecordGroupField.ts @@ -105,14 +105,12 @@ export const useHandleRecordGroupField = () => { ); if (viewGroupsToCreate.length > 0) { - await createViewGroups( - viewGroupsToCreate.map(({ __typename, ...viewGroup }) => ({ - input: { - ...viewGroup, - viewId: view.id, - }, + await createViewGroups({ + inputs: viewGroupsToCreate.map(({ __typename, ...viewGroup }) => ({ + ...viewGroup, + viewId: view.id, })), - ); + }); } if (viewGroupsToDelete.length > 0) { diff --git a/packages/twenty-front/src/modules/views/graphql/mutations/createManyCoreViewGroups.ts b/packages/twenty-front/src/modules/views/graphql/mutations/createManyCoreViewGroups.ts new file mode 100644 index 0000000000..5c25116765 --- /dev/null +++ b/packages/twenty-front/src/modules/views/graphql/mutations/createManyCoreViewGroups.ts @@ -0,0 +1,11 @@ +import { VIEW_GROUP_FRAGMENT } from '@/views/graphql/fragments/viewGroupFragment'; +import { gql } from '@apollo/client'; + +export const CREATE_MANY_CORE_VIEW_GROUPS = gql` + ${VIEW_GROUP_FRAGMENT} + mutation CreateManyCoreViewGroups($inputs: [CreateViewGroupInput!]!) { + createManyCoreViewGroups(inputs: $inputs) { + ...ViewGroupFragment + } + } +`; diff --git a/packages/twenty-front/src/modules/views/hooks/internal/usePersistViewGroup.ts b/packages/twenty-front/src/modules/views/hooks/internal/usePersistViewGroup.ts index f2528af025..13919b7fb1 100644 --- a/packages/twenty-front/src/modules/views/hooks/internal/usePersistViewGroup.ts +++ b/packages/twenty-front/src/modules/views/hooks/internal/usePersistViewGroup.ts @@ -4,15 +4,19 @@ import { useMetadataErrorHandler } from '@/metadata-error-handler/hooks/useMetad import { type MetadataRequestResult } from '@/object-metadata/types/MetadataRequestResult.type'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { useTriggerViewGroupOptimisticEffect } from '@/views/optimistic-effects/hooks/useTriggerViewGroupOptimisticEffect'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import { ApolloError } from '@apollo/client'; import { t } from '@lingui/core/macro'; import { isDefined } from 'twenty-shared/utils'; import { type CreateCoreViewGroupMutationVariables, + type CreateManyCoreViewGroupsMutationVariables, type DeleteCoreViewGroupMutationVariables, type DestroyCoreViewGroupMutationVariables, + FeatureFlagKey, type UpdateCoreViewGroupMutationVariables, useCreateCoreViewGroupMutation, + useCreateManyCoreViewGroupsMutation, useDeleteCoreViewGroupMutation, useDestroyCoreViewGroupMutation, useUpdateCoreViewGroupMutation, @@ -21,7 +25,14 @@ import { export const usePersistViewGroupRecords = () => { const { triggerViewGroupOptimisticEffect } = useTriggerViewGroupOptimisticEffect(); + + const isWorkspaceMigrationV2Enabled = useIsFeatureEnabled( + FeatureFlagKey.IS_WORKSPACE_MIGRATION_V2_ENABLED, + ); + const [createCoreViewGroupMutation] = useCreateCoreViewGroupMutation(); + const [createManyCoreViewGroupsMutation] = + useCreateManyCoreViewGroupsMutation(); const [updateCoreViewGroupMutation] = useUpdateCoreViewGroupMutation(); const [deleteCoreViewGroupMutation] = useDeleteCoreViewGroupMutation(); const [destroyCoreViewGroupMutation] = useDestroyCoreViewGroupMutation(); @@ -29,7 +40,7 @@ export const usePersistViewGroupRecords = () => { const { handleMetadataError } = useMetadataErrorHandler(); const { enqueueErrorSnackBar } = useSnackBar(); - const createViewGroups = useCallback( + const oldCreateViewGroups = useCallback( async ( createCoreViewGroupInputs: CreateCoreViewGroupMutationVariables[], ): Promise< @@ -46,7 +57,7 @@ export const usePersistViewGroupRecords = () => { try { const results = await Promise.all( - createCoreViewGroupInputs.map((variables) => + createCoreViewGroupInputs.map(async (variables) => createCoreViewGroupMutation({ variables, update: (_cache, { data }) => { @@ -90,6 +101,77 @@ export const usePersistViewGroupRecords = () => { ], ); + const createViewGroups = useCallback( + async ( + createCoreViewGroupInputs: CreateManyCoreViewGroupsMutationVariables, + ): Promise< + | MetadataRequestResult + > | null> + | MetadataRequestResult< + Awaited>[] + > + > => { + if ( + !Array.isArray(createCoreViewGroupInputs.inputs) || + createCoreViewGroupInputs.inputs.length === 0 + ) { + return { + status: 'successful', + response: null, + }; + } + + if (!isWorkspaceMigrationV2Enabled) { + const oldFormatInputs: CreateCoreViewGroupMutationVariables[] = + createCoreViewGroupInputs.inputs.map((input) => ({ input })); + return await oldCreateViewGroups(oldFormatInputs); + } + + try { + const result = await createManyCoreViewGroupsMutation({ + variables: createCoreViewGroupInputs, + update: (_cache, { data }) => { + const createdViewGroups = data?.createManyCoreViewGroups; + if (!isDefined(createdViewGroups)) { + return; + } + + triggerViewGroupOptimisticEffect({ + createdViewGroups, + }); + }, + }); + + return { + status: 'successful', + response: result, + }; + } catch (error) { + if (error instanceof ApolloError) { + handleMetadataError(error, { + primaryMetadataName: 'viewGroup', + }); + } else { + enqueueErrorSnackBar({ message: t`An error occurred.` }); + } + + return { + status: 'failed', + error, + }; + } + }, + [ + isWorkspaceMigrationV2Enabled, + oldCreateViewGroups, + triggerViewGroupOptimisticEffect, + createManyCoreViewGroupsMutation, + handleMetadataError, + enqueueErrorSnackBar, + ], + ); + const updateViewGroups = useCallback( async ( updateCoreViewGroupInputs: UpdateCoreViewGroupMutationVariables[], diff --git a/packages/twenty-front/src/modules/views/hooks/useCreateViewFromCurrentView.ts b/packages/twenty-front/src/modules/views/hooks/useCreateViewFromCurrentView.ts index fd471348e6..349403fe11 100644 --- a/packages/twenty-front/src/modules/views/hooks/useCreateViewFromCurrentView.ts +++ b/packages/twenty-front/src/modules/views/hooks/useCreateViewFromCurrentView.ts @@ -204,14 +204,12 @@ export const useCreateViewFromCurrentView = (viewBarComponentId?: string) => { fieldMetadataId: kanbanFieldMetadataId, } satisfies ViewGroup); - const groupResult = await createViewGroups( - viewGroupsToCreate.map(({ __typename, ...viewGroup }) => ({ - input: { - ...viewGroup, - viewId: newViewId, - }, + const groupResult = await createViewGroups({ + inputs: viewGroupsToCreate.map(({ __typename, ...viewGroup }) => ({ + ...viewGroup, + viewId: newViewId, })), - ); + }); if (groupResult.status === 'failed') { set(isPersistingViewFieldsState, false); diff --git a/packages/twenty-front/src/modules/views/hooks/useSaveCurrentViewGroups.ts b/packages/twenty-front/src/modules/views/hooks/useSaveCurrentViewGroups.ts index 07e1eccea9..042ba9bc1d 100644 --- a/packages/twenty-front/src/modules/views/hooks/useSaveCurrentViewGroups.ts +++ b/packages/twenty-front/src/modules/views/hooks/useSaveCurrentViewGroups.ts @@ -146,14 +146,12 @@ export const useSaveCurrentViewGroups = () => { ); await Promise.all([ - createViewGroups( - viewGroupsToCreate.map(({ __typename, ...viewGroup }) => ({ - input: { - ...viewGroup, - viewId: view.id, - }, + createViewGroups({ + inputs: viewGroupsToCreate.map(({ __typename, ...viewGroup }) => ({ + ...viewGroup, + viewId: view.id, })), - ), + }), updateViewGroups(viewGroupsToUpdate), ]); }, diff --git a/packages/twenty-server/src/engine/metadata-modules/view-group/resolvers/view-group.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/view-group/resolvers/view-group.resolver.ts index 5b7ee80ed9..56090f2ac8 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view-group/resolvers/view-group.resolver.ts +++ b/packages/twenty-server/src/engine/metadata-modules/view-group/resolvers/view-group.resolver.ts @@ -11,6 +11,10 @@ import { DeleteViewGroupInput } from 'src/engine/metadata-modules/view-group/dto import { DestroyViewGroupInput } from 'src/engine/metadata-modules/view-group/dtos/inputs/destroy-view-group.input'; import { UpdateViewGroupInput } from 'src/engine/metadata-modules/view-group/dtos/inputs/update-view-group.input'; import { ViewGroupDTO } from 'src/engine/metadata-modules/view-group/dtos/view-group.dto'; +import { + ViewGroupException, + ViewGroupExceptionCode, +} from 'src/engine/metadata-modules/view-group/exceptions/view-group.exception'; import { ViewGroupV2Service } from 'src/engine/metadata-modules/view-group/services/view-group-v2.service'; import { ViewGroupService } from 'src/engine/metadata-modules/view-group/services/view-group.service'; import { ViewGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/view/utils/view-graphql-api-exception.filter'; @@ -70,6 +74,31 @@ export class ViewGroupResolver { }); } + @Mutation(() => [ViewGroupDTO]) + async createManyCoreViewGroups( + @Args('inputs', { type: () => [CreateViewGroupInput] }) + createViewGroupInputs: CreateViewGroupInput[], + @AuthWorkspace() { id: workspaceId }: WorkspaceEntity, + ): Promise { + const isWorkspaceMigrationV2Enabled = + await this.featureFlagService.isFeatureEnabled( + FeatureFlagKey.IS_WORKSPACE_MIGRATION_V2_ENABLED, + workspaceId, + ); + + if (!isWorkspaceMigrationV2Enabled) { + throw new ViewGroupException( + 'Not implemented in v1, please active IS_WORKSPACE_MIGRATION_V2_ENABLED', + ViewGroupExceptionCode.INVALID_VIEW_GROUP_DATA, + ); + } + + return await this.viewGroupV2Service.createMany({ + createViewGroupInputs, + workspaceId, + }); + } + @Mutation(() => ViewGroupDTO) async updateCoreViewGroup( @Args('input') updateViewGroupInput: UpdateViewGroupInput, diff --git a/packages/twenty-server/src/engine/metadata-modules/view-group/services/view-group-v2.service.ts b/packages/twenty-server/src/engine/metadata-modules/view-group/services/view-group-v2.service.ts index d904eb0e6f..188a16e8d1 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view-group/services/view-group-v2.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/view-group/services/view-group-v2.service.ts @@ -5,6 +5,7 @@ import { isDefined } from 'twenty-shared/utils'; import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; import { computeFlatEntityMapsFromTo } from 'src/engine/metadata-modules/flat-entity/utils/compute-flat-entity-maps-from-to.util'; import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util'; +import { findManyFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-many-flat-entity-by-id-in-flat-entity-maps-or-throw.util'; import { fromCreateViewGroupInputToFlatViewGroupToCreate } from 'src/engine/metadata-modules/flat-view-group/utils/from-create-view-group-input-to-flat-view-group-to-create.util'; import { fromDeleteViewGroupInputToFlatViewGroupOrThrow } from 'src/engine/metadata-modules/flat-view-group/utils/from-delete-view-group-input-to-flat-view-group-or-throw.util'; import { fromDestroyViewGroupInputToFlatViewGroupOrThrow } from 'src/engine/metadata-modules/flat-view-group/utils/from-destroy-view-group-input-to-flat-view-group-or-throw.util'; @@ -14,6 +15,10 @@ import { DeleteViewGroupInput } from 'src/engine/metadata-modules/view-group/dto import { DestroyViewGroupInput } from 'src/engine/metadata-modules/view-group/dtos/inputs/destroy-view-group.input'; import { UpdateViewGroupInput } from 'src/engine/metadata-modules/view-group/dtos/inputs/update-view-group.input'; import { ViewGroupDTO } from 'src/engine/metadata-modules/view-group/dtos/view-group.dto'; +import { + ViewGroupException, + ViewGroupExceptionCode, +} from 'src/engine/metadata-modules/view-group/exceptions/view-group.exception'; import { WorkspaceMigrationBuilderExceptionV2 } from 'src/engine/workspace-manager/workspace-migration-v2/exceptions/workspace-migration-builder-exception-v2'; import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration-v2/services/workspace-migration-validate-build-and-run-service'; @@ -31,6 +36,32 @@ export class ViewGroupV2Service { createViewGroupInput: CreateViewGroupInput; workspaceId: string; }): Promise { + const [createdViewGroup] = await this.createMany({ + workspaceId, + createViewGroupInputs: [createViewGroupInput], + }); + + if (!isDefined(createdViewGroup)) { + throw new ViewGroupException( + 'Failed to create view group', + ViewGroupExceptionCode.INVALID_VIEW_GROUP_DATA, + ); + } + + return createdViewGroup; + } + + async createMany({ + createViewGroupInputs, + workspaceId, + }: { + createViewGroupInputs: CreateViewGroupInput[]; + workspaceId: string; + }): Promise { + if (createViewGroupInputs.length === 0) { + return []; + } + const { flatViewGroupMaps: existingFlatViewGroupMaps, flatViewMaps, @@ -46,11 +77,13 @@ export class ViewGroupV2Service { }, ); - const flatViewGroupToCreate = - fromCreateViewGroupInputToFlatViewGroupToCreate({ - createViewGroupInput, - workspaceId, - }); + const flatViewGroupsToCreate = createViewGroupInputs.map( + (createViewGroupInput) => + fromCreateViewGroupInputToFlatViewGroupToCreate({ + createViewGroupInput, + workspaceId, + }), + ); const validateAndBuildResult = await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration( @@ -58,7 +91,7 @@ export class ViewGroupV2Service { fromToAllFlatEntityMaps: { flatViewGroupMaps: computeFlatEntityMapsFromTo({ flatEntityMaps: existingFlatViewGroupMaps, - flatEntityToCreate: [flatViewGroupToCreate], + flatEntityToCreate: flatViewGroupsToCreate, flatEntityToDelete: [], flatEntityToUpdate: [], }), @@ -77,7 +110,7 @@ export class ViewGroupV2Service { if (isDefined(validateAndBuildResult)) { throw new WorkspaceMigrationBuilderExceptionV2( validateAndBuildResult, - 'Multiple validation errors occurred while creating view group', + 'Multiple validation errors occurred while creating view groups', ); } @@ -89,8 +122,8 @@ export class ViewGroupV2Service { }, ); - return findFlatEntityByIdInFlatEntityMapsOrThrow({ - flatEntityId: flatViewGroupToCreate.id, + return findManyFlatEntityByIdInFlatEntityMapsOrThrow({ + flatEntityIds: flatViewGroupsToCreate.map((el) => el.id), flatEntityMaps: recomputedExistingFlatViewGroupMaps, }); } diff --git a/packages/twenty-server/test/integration/graphql/suites/view/view-group/__snapshots__/failing-create-many-view-groups-v2.integration-spec.ts.snap b/packages/twenty-server/test/integration/graphql/suites/view/view-group/__snapshots__/failing-create-many-view-groups-v2.integration-spec.ts.snap new file mode 100644 index 0000000000..ddf83e9535 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/view/view-group/__snapshots__/failing-create-many-view-groups-v2.integration-spec.ts.snap @@ -0,0 +1,94 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`View Group Resolver - Failing Create Many Operations - v2 should accumulate multiple validation errors when some inputs are invalid 1`] = ` +{ + "extensions": { + "code": "METADATA_VALIDATION_FAILED", + "errors": { + "cronTrigger": [], + "databaseEventTrigger": [], + "fieldMetadata": [], + "index": [], + "objectMetadata": [], + "routeTrigger": [], + "serverlessFunction": [], + "view": [], + "viewField": [], + "viewFilter": [], + "viewGroup": [ + { + "errors": [ + { + "code": "INVALID_VIEW_DATA", + "message": "Field metadata not found", + "userFriendlyMessage": "Field metadata not found", + }, + ], + "flatEntityMinimalInformation": { + "fieldMetadataId": Any, + "id": Any, + "viewId": Any, + }, + "status": "fail", + "type": "create_view_group", + }, + { + "errors": [ + { + "code": "INVALID_VIEW_DATA", + "message": "View not found", + "userFriendlyMessage": "View not found", + }, + ], + "flatEntityMinimalInformation": { + "fieldMetadataId": Any, + "id": Any, + "viewId": Any, + }, + "status": "fail", + "type": "create_view_group", + }, + { + "errors": [ + { + "code": "INVALID_VIEW_DATA", + "message": "Field metadata not found", + "userFriendlyMessage": "Field metadata not found", + }, + { + "code": "INVALID_VIEW_DATA", + "message": "View not found", + "userFriendlyMessage": "View not found", + }, + ], + "flatEntityMinimalInformation": { + "fieldMetadataId": Any, + "id": Any, + "viewId": Any, + }, + "status": "fail", + "type": "create_view_group", + }, + ], + }, + "message": "Validation failed for 0 object(s) and 0 field(s)", + "summary": { + "invalidCronTrigger": 0, + "invalidDatabaseEventTrigger": 0, + "invalidFieldMetadata": 0, + "invalidIndex": 0, + "invalidObjectMetadata": 0, + "invalidRouteTrigger": 0, + "invalidServerlessFunction": 0, + "invalidView": 0, + "invalidViewField": 0, + "invalidViewFilter": 0, + "invalidViewGroup": 0, + "totalErrors": 0, + }, + "userFriendlyMessage": "Validation failed for 0 object(s) and 0 field(s)", + }, + "message": "Multiple validation errors occurred while creating view groups", + "name": "GraphQLError", +} +`; diff --git a/packages/twenty-server/test/integration/graphql/suites/view/view-group/failing-create-many-view-groups-v2.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/view/view-group/failing-create-many-view-groups-v2.integration-spec.ts new file mode 100644 index 0000000000..5a817b5cf5 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/view/view-group/failing-create-many-view-groups-v2.integration-spec.ts @@ -0,0 +1,152 @@ +import { createManyCoreViewGroups } from 'test/integration/metadata/suites/view-group/utils/create-many-core-view-groups.util'; +import { v4 as uuidv4 } from 'uuid'; +import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util'; +import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util'; +import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util'; +import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util'; +import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util'; +import { createOneCoreView } from 'test/integration/metadata/suites/view/utils/create-one-core-view.util'; +import { FieldMetadataType } from 'twenty-shared/types'; + +import { type CreateViewGroupInput } from 'src/engine/metadata-modules/view-group/dtos/inputs/create-view-group.input'; + +describe('View Group Resolver - Failing Create Many Operations - v2', () => { + let testSetup: { + testViewId: string; + testObjectMetadataId: string; + firstTestFieldMetadataId: string; + secondTestFieldMetadataId: string; + }; + + beforeAll(async () => { + const { + data: { + createOneObject: { id: objectMetadataId }, + }, + } = await createOneObjectMetadata({ + expectToFail: false, + input: { + nameSingular: 'myGroupTestObjectV2', + namePlural: 'myGroupTestObjectsV2', + labelSingular: 'My Group Test Object v2', + labelPlural: 'My Group Test Objects v2', + icon: 'Icon123', + }, + }); + + const { + data: { + createOneField: { id: firstTestFieldMetadataId }, + }, + } = await createOneFieldMetadata({ + expectToFail: false, + input: { + name: 'testField', + label: 'Test Field', + type: FieldMetadataType.TEXT, + objectMetadataId, + isLabelSyncedWithName: true, + }, + gqlFields: ` + id + name + label + isLabelSyncedWithName + `, + }); + + const { + data: { + createOneField: { id: secondTestFieldMetadataId }, + }, + } = await createOneFieldMetadata({ + expectToFail: false, + input: { + name: 'secondTestField', + label: 'Test Field', + type: FieldMetadataType.TEXT, + objectMetadataId, + isLabelSyncedWithName: false, + }, + gqlFields: ` + id + name + label + isLabelSyncedWithName + `, + }); + + const { + data: { + createCoreView: { id: testViewId }, + }, + } = await createOneCoreView({ + input: { + icon: 'icon123', + objectMetadataId, + name: 'TestViewForGroups', + }, + expectToFail: false, + }); + + testSetup = { + testViewId, + testObjectMetadataId: objectMetadataId, + firstTestFieldMetadataId, + secondTestFieldMetadataId, + }; + }); + + afterAll(async () => { + await updateOneObjectMetadata({ + input: { + idToUpdate: testSetup.testObjectMetadataId, + updatePayload: { + isActive: false, + }, + }, + }); + await deleteOneObjectMetadata({ + expectToFail: false, + input: { idToDelete: testSetup.testObjectMetadataId }, + }); + }); + + it('should accumulate multiple validation errors when some inputs are invalid', async () => { + const invalidViewId = uuidv4(); + const invalidFieldMetadataId = uuidv4(); + + const inputs: CreateViewGroupInput[] = [ + { + fieldMetadataId: invalidFieldMetadataId, + viewId: testSetup.testViewId, + position: 0, + isVisible: true, + fieldValue: 'Invalid Group A', + }, + { + fieldMetadataId: testSetup.firstTestFieldMetadataId, + viewId: invalidViewId, + position: 1, + isVisible: true, + fieldValue: 'Invalid Group B', + }, + { + fieldMetadataId: invalidFieldMetadataId, + viewId: invalidViewId, + position: 2, + isVisible: true, + fieldValue: 'Invalid Group C', + }, + ]; + + const { errors } = await createManyCoreViewGroups({ + inputs, + expectToFail: true, + }); + + expectOneNotInternalServerErrorSnapshot({ + errors, + }); + }); +}); diff --git a/packages/twenty-server/test/integration/graphql/suites/view/view-group/successful-create-many-view-groups-v2.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/view/view-group/successful-create-many-view-groups-v2.integration-spec.ts new file mode 100644 index 0000000000..fc9cf21149 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/view/view-group/successful-create-many-view-groups-v2.integration-spec.ts @@ -0,0 +1,265 @@ +import { createOneFieldMetadata } from 'test/integration/metadata/suites/field-metadata/utils/create-one-field-metadata.util'; +import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util'; +import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util'; +import { updateOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/update-one-object-metadata.util'; +import { createManyCoreViewGroups } from 'test/integration/metadata/suites/view-group/utils/create-many-core-view-groups.util'; +import { deleteOneCoreViewGroup } from 'test/integration/metadata/suites/view-group/utils/delete-one-core-view-group.util'; +import { destroyOneCoreViewGroup } from 'test/integration/metadata/suites/view-group/utils/destroy-one-core-view-group.util'; +import { createOneCoreView } from 'test/integration/metadata/suites/view/utils/create-one-core-view.util'; +import { FieldMetadataType } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +import { type CreateViewGroupInput } from 'src/engine/metadata-modules/view-group/dtos/inputs/create-view-group.input'; + +describe('View Group Resolver - Successful Create Many Operations - v2', () => { + let testSetup: { + testViewId: string; + testObjectMetadataId: string; + firstTestFieldMetadataId: string; + secondTestFieldMetadataId: string; + thirdTestFieldMetadataId: string; + }; + let createdViewGroupIds: string[] = []; + + beforeAll(async () => { + const { + data: { + createOneObject: { id: objectMetadataId }, + }, + } = await createOneObjectMetadata({ + expectToFail: false, + input: { + nameSingular: 'myGroupTestObjectV2', + namePlural: 'myGroupTestObjectsV2', + labelSingular: 'My Group Test Object v2', + labelPlural: 'My Group Test Objects v2', + icon: 'Icon123', + }, + }); + + const { + data: { + createOneField: { id: firstTestFieldMetadataId }, + }, + } = await createOneFieldMetadata({ + expectToFail: false, + input: { + name: 'testField', + label: 'Test Field', + type: FieldMetadataType.TEXT, + objectMetadataId, + isLabelSyncedWithName: true, + }, + gqlFields: ` + id + name + label + isLabelSyncedWithName + `, + }); + + const { + data: { + createOneField: { id: secondTestFieldMetadataId }, + }, + } = await createOneFieldMetadata({ + expectToFail: false, + input: { + name: 'secondTestField', + label: 'Test Field', + type: FieldMetadataType.TEXT, + objectMetadataId, + isLabelSyncedWithName: false, + }, + gqlFields: ` + id + name + label + isLabelSyncedWithName + `, + }); + + const { + data: { + createOneField: { id: thirdTestFieldMetadataId }, + }, + } = await createOneFieldMetadata({ + expectToFail: false, + input: { + name: 'thirdTestField', + label: 'Test Field', + type: FieldMetadataType.TEXT, + objectMetadataId, + isLabelSyncedWithName: false, + }, + gqlFields: ` + id + name + label + isLabelSyncedWithName + `, + }); + + const { + data: { + createCoreView: { id: testViewId }, + }, + } = await createOneCoreView({ + input: { + icon: 'icon123', + objectMetadataId, + name: 'TestViewForGroups', + }, + expectToFail: false, + }); + + testSetup = { + testViewId, + testObjectMetadataId: objectMetadataId, + firstTestFieldMetadataId, + secondTestFieldMetadataId, + thirdTestFieldMetadataId, + }; + }); + + afterAll(async () => { + await updateOneObjectMetadata({ + input: { + idToUpdate: testSetup.testObjectMetadataId, + updatePayload: { + isActive: false, + }, + }, + }); + await deleteOneObjectMetadata({ + expectToFail: false, + input: { idToDelete: testSetup.testObjectMetadataId }, + }); + }); + + afterEach(async () => { + for (const viewGroupId of createdViewGroupIds) { + if (isDefined(viewGroupId)) { + const { + data: { deleteCoreViewGroup }, + } = await deleteOneCoreViewGroup({ + expectToFail: false, + input: { + id: viewGroupId, + }, + }); + + expect(deleteCoreViewGroup.deletedAt).not.toBeNull(); + await destroyOneCoreViewGroup({ + expectToFail: false, + input: { + id: viewGroupId, + }, + }); + } + } + createdViewGroupIds = []; + }); + + it('should successfully create multiple view groups in batch', async () => { + const inputs: CreateViewGroupInput[] = [ + { + fieldMetadataId: testSetup.firstTestFieldMetadataId, + viewId: testSetup.testViewId, + position: 0, + isVisible: true, + fieldValue: 'Group A', + }, + { + fieldMetadataId: testSetup.secondTestFieldMetadataId, + viewId: testSetup.testViewId, + position: 1, + isVisible: false, + fieldValue: 'Group B', + }, + { + fieldMetadataId: testSetup.thirdTestFieldMetadataId, + viewId: testSetup.testViewId, + position: 2, + isVisible: true, + fieldValue: 'Group C', + }, + ]; + + const { + data: { createManyCoreViewGroups: createdViewGroups }, + errors, + } = await createManyCoreViewGroups({ + inputs, + expectToFail: false, + }); + + expect(errors).toBeUndefined(); + expect(createdViewGroups).toBeDefined(); + expect(createdViewGroups).toHaveLength(3); + + createdViewGroups.forEach((viewGroup, index) => { + expect(viewGroup).toMatchObject({ + fieldMetadataId: inputs[index].fieldMetadataId, + viewId: testSetup.testViewId, + position: inputs[index].position, + isVisible: inputs[index].isVisible, + fieldValue: inputs[index].fieldValue, + }); + + createdViewGroupIds.push(viewGroup.id); + }); + }); + + it('should successfully create single view group using batch endpoint', async () => { + const inputs: CreateViewGroupInput[] = [ + { + fieldMetadataId: testSetup.firstTestFieldMetadataId, + viewId: testSetup.testViewId, + position: 5, + isVisible: true, + fieldValue: 'Single Group', + }, + ]; + + const { + data: { createManyCoreViewGroups: createdViewGroups }, + errors, + } = await createManyCoreViewGroups({ + inputs, + expectToFail: false, + }); + + expect(errors).toBeUndefined(); + expect(createdViewGroups).toBeDefined(); + expect(createdViewGroups).toHaveLength(1); + + const viewGroup = createdViewGroups[0]; + + expect(viewGroup).toMatchObject({ + fieldMetadataId: testSetup.firstTestFieldMetadataId, + viewId: testSetup.testViewId, + position: 5, + isVisible: true, + fieldValue: 'Single Group', + }); + + createdViewGroupIds.push(viewGroup.id); + }); + + it('should return empty array when creating zero view groups', async () => { + const inputs: CreateViewGroupInput[] = []; + + const { + data: { createManyCoreViewGroups: createdViewGroups }, + errors, + } = await createManyCoreViewGroups({ + inputs, + expectToFail: false, + }); + + expect(errors).toBeUndefined(); + expect(createdViewGroups).toBeDefined(); + expect(createdViewGroups).toHaveLength(0); + }); +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/view-group/utils/create-many-core-view-groups-query-factory.util.ts b/packages/twenty-server/test/integration/metadata/suites/view-group/utils/create-many-core-view-groups-query-factory.util.ts new file mode 100644 index 0000000000..730ea7b6de --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/view-group/utils/create-many-core-view-groups-query-factory.util.ts @@ -0,0 +1,23 @@ +import gql from 'graphql-tag'; +import { VIEW_GROUP_GQL_FIELDS } from 'test/integration/constants/view-gql-fields.constants'; + +import { type CreateViewGroupInput } from 'src/engine/metadata-modules/view-group/dtos/inputs/create-view-group.input'; + +export const createManyCoreViewGroupsQueryFactory = ({ + gqlFields = VIEW_GROUP_GQL_FIELDS, + inputs, +}: { + gqlFields?: string; + inputs: CreateViewGroupInput[]; +}) => ({ + query: gql` + mutation CreateManyCoreViewGroups($inputs: [CreateViewGroupInput!]!) { + createManyCoreViewGroups(inputs: $inputs) { + ${gqlFields} + } + } + `, + variables: { + inputs, + }, +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/view-group/utils/create-many-core-view-groups.util.ts b/packages/twenty-server/test/integration/metadata/suites/view-group/utils/create-many-core-view-groups.util.ts new file mode 100644 index 0000000000..2a3ca804e3 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/view-group/utils/create-many-core-view-groups.util.ts @@ -0,0 +1,43 @@ +import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util'; +import { createManyCoreViewGroupsQueryFactory } from 'test/integration/metadata/suites/view-group/utils/create-many-core-view-groups-query-factory.util'; +import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type'; +import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util'; +import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util'; + +import { type CreateViewGroupInput } from 'src/engine/metadata-modules/view-group/dtos/inputs/create-view-group.input'; +import { type ViewGroupEntity } from 'src/engine/metadata-modules/view-group/entities/view-group.entity'; + +export const createManyCoreViewGroups = async ({ + inputs, + gqlFields, + expectToFail, +}: { + inputs: CreateViewGroupInput[]; + gqlFields?: string; + expectToFail?: boolean; +}): CommonResponseBody<{ + createManyCoreViewGroups: ViewGroupEntity[]; +}> => { + const graphqlOperation = createManyCoreViewGroupsQueryFactory({ + inputs, + gqlFields, + }); + + const response = await makeMetadataAPIRequest(graphqlOperation); + + if (expectToFail === true) { + warnIfNoErrorButExpectedToFail({ + response, + errorMessage: 'View Groups batch creation should have failed but did not', + }); + } + + if (expectToFail === false) { + warnIfErrorButNotExpectedToFail({ + response, + errorMessage: 'View Groups batch creation has failed but should not', + }); + } + + return { data: response.body.data, errors: response.body.errors }; +}; diff --git a/packages/twenty-server/test/integration/rest/suites/__snapshots__/view-group.integration-spec.ts.snap b/packages/twenty-server/test/integration/rest/suites/__snapshots__/view-group.integration-spec.ts.snap index 6e3cc69a0a..e181b6bac8 100644 --- a/packages/twenty-server/test/integration/rest/suites/__snapshots__/view-group.integration-spec.ts.snap +++ b/packages/twenty-server/test/integration/rest/suites/__snapshots__/view-group.integration-spec.ts.snap @@ -32,7 +32,7 @@ exports[`View Group REST API POST /metadata/viewGroups should fail to create vie }, ], }, - "message": "Multiple validation errors occurred while creating view group", + "message": "Multiple validation errors occurred while creating view groups", "statusCode": 400, "summary": { "invalidCronTrigger": 0,