From 6f0ac88e20056dfe0d1f1d36950a5f43c9b84ef6 Mon Sep 17 00:00:00 2001 From: Rayan Salhab Date: Fri, 27 Mar 2026 10:13:51 +0200 Subject: [PATCH] fix: batch viewGroup mutations sequentially to prevent race conditions (#19027) ## Bug Description When reordering stages in the Kanban board, the frontend fires all viewGroup update mutations concurrently via Promise.all, causing race conditions in the workspace migration runner's cache invalidation, database contention, and a thundering herd effect that stalls the server. ## Changes Changed `usePerformViewGroupAPIPersist` to execute viewGroup update mutations sequentially instead of concurrently. The `Promise.all` pattern fired all N mutations simultaneously, each triggering a full workspace migration runner pipeline (transaction + cache invalidation). The sequential `for...of` loop ensures each mutation completes (including its cache invalidation) before the next begins, eliminating the race condition. ## Related Issue Fixes #18865 ## Testing This fix addresses the root cause identified in the Sonarly analysis on the issue. The concurrent mutation pattern was causing: - PostgreSQL row-level lock contention on viewGroup rows - Cache thundering herd from repeated invalidation/recomputation cycles - Server stalls requiring container restarts The sequential approach ensures proper ordering and prevents these race conditions. --------- Co-authored-by: Rayan Co-authored-by: Charles Bochet --- .../src/metadata/generated/schema.graphql | 1 + .../src/metadata/generated/schema.ts | 2 + .../src/metadata/generated/types.ts | 9 + .../src/generated-metadata/graphql.ts | 14 ++ .../graphql/mutations/updateManyViewGroups.ts | 11 + .../internal/usePerformViewGroupAPIPersist.ts | 37 +-- .../views/hooks/useSaveCurrentViewGroups.ts | 26 +- .../resolvers/view-group.resolver.ts | 13 + .../view-group/services/view-group.service.ts | 56 ++++- ...te-many-view-groups-v2.integration-spec.ts | 235 ++++++++++++++++++ ...ate-many-view-groups-query-factory.util.ts | 23 ++ .../utils/update-many-view-groups.util.ts | 45 ++++ 12 files changed, 427 insertions(+), 45 deletions(-) create mode 100644 packages/twenty-front/src/modules/views/graphql/mutations/updateManyViewGroups.ts create mode 100644 packages/twenty-server/test/integration/graphql/suites/view/view-group/successful-update-many-view-groups-v2.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/view-group/utils/update-many-view-groups-query-factory.util.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/view-group/utils/update-many-view-groups.util.ts diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql index 543ca8b32f..51489facfc 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql @@ -3501,6 +3501,7 @@ type Mutation { createViewGroup(input: CreateViewGroupInput!): ViewGroup! createManyViewGroups(inputs: [CreateViewGroupInput!]!): [ViewGroup!]! updateViewGroup(input: UpdateViewGroupInput!): ViewGroup! + updateManyViewGroups(inputs: [UpdateViewGroupInput!]!): [ViewGroup!]! deleteViewGroup(input: DeleteViewGroupInput!): ViewGroup! destroyViewGroup(input: DestroyViewGroupInput!): ViewGroup! updateMessageFolder(input: UpdateMessageFolderInput!): MessageFolder! diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.ts b/packages/twenty-client-sdk/src/metadata/generated/schema.ts index a0b70417f8..52fd7b1c52 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.ts @@ -2954,6 +2954,7 @@ export interface Mutation { createViewGroup: ViewGroup createManyViewGroups: ViewGroup[] updateViewGroup: ViewGroup + updateManyViewGroups: ViewGroup[] deleteViewGroup: ViewGroup destroyViewGroup: ViewGroup updateMessageFolder: MessageFolder @@ -6207,6 +6208,7 @@ export interface MutationGenqlSelection{ createViewGroup?: (ViewGroupGenqlSelection & { __args: {input: CreateViewGroupInput} }) createManyViewGroups?: (ViewGroupGenqlSelection & { __args: {inputs: CreateViewGroupInput[]} }) updateViewGroup?: (ViewGroupGenqlSelection & { __args: {input: UpdateViewGroupInput} }) + updateManyViewGroups?: (ViewGroupGenqlSelection & { __args: {inputs: UpdateViewGroupInput[]} }) deleteViewGroup?: (ViewGroupGenqlSelection & { __args: {input: DeleteViewGroupInput} }) destroyViewGroup?: (ViewGroupGenqlSelection & { __args: {input: DestroyViewGroupInput} }) updateMessageFolder?: (MessageFolderGenqlSelection & { __args: {input: UpdateMessageFolderInput} }) diff --git a/packages/twenty-client-sdk/src/metadata/generated/types.ts b/packages/twenty-client-sdk/src/metadata/generated/types.ts index ada7e65f35..3b47795bc8 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/types.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/types.ts @@ -8130,6 +8130,15 @@ export default { ] } ], + "updateManyViewGroups": [ + 56, + { + "inputs": [ + 450, + "[UpdateViewGroupInput!]!" + ] + } + ], "deleteViewGroup": [ 56, { diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 65017e3869..b8526b98fa 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -2703,6 +2703,7 @@ export type Mutation = { updateFrontComponent: FrontComponent; updateLabPublicFeatureFlag: FeatureFlag; updateManyNavigationMenuItems: Array; + updateManyViewGroups: Array; updateMessageChannel: MessageChannel; updateMessageFolder: MessageFolder; updateMessageFolders: Array; @@ -3513,6 +3514,11 @@ export type MutationUpdateManyNavigationMenuItemsArgs = { }; +export type MutationUpdateManyViewGroupsArgs = { + inputs: Array; +}; + + export type MutationUpdateMessageChannelArgs = { input: UpdateMessageChannelInput; }; @@ -7862,6 +7868,13 @@ export type DestroyViewSortMutationVariables = Exact<{ export type DestroyViewSortMutation = { __typename?: 'Mutation', destroyViewSort: boolean }; +export type UpdateManyViewGroupsMutationVariables = Exact<{ + inputs: Array | UpdateViewGroupInput; +}>; + + +export type UpdateManyViewGroupsMutation = { __typename?: 'Mutation', updateManyViewGroups: Array<{ __typename?: 'ViewGroup', id: string, isVisible: boolean, fieldValue: string, position: number, viewId: string, createdAt: string, updatedAt: string, deletedAt?: string | null }> }; + export type UpdateViewMutationVariables = Exact<{ id: Scalars['String']; input: UpdateViewInput; @@ -8361,6 +8374,7 @@ export const DestroyViewFilterDocument = {"kind":"Document","definitions":[{"kin export const DestroyViewFilterGroupDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DestroyViewFilterGroup"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"destroyViewFilterGroup"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}]}]}}]} as unknown as DocumentNode; export const DestroyViewGroupDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DestroyViewGroup"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"DestroyViewGroupInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"destroyViewGroup"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ViewGroupFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ViewGroupFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ViewGroup"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"isVisible"}},{"kind":"Field","name":{"kind":"Name","value":"fieldValue"}},{"kind":"Field","name":{"kind":"Name","value":"position"}},{"kind":"Field","name":{"kind":"Name","value":"viewId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"deletedAt"}}]}}]} as unknown as DocumentNode; export const DestroyViewSortDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DestroyViewSort"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"DestroyViewSortInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"destroyViewSort"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]} as unknown as DocumentNode; +export const UpdateManyViewGroupsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateManyViewGroups"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"inputs"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateViewGroupInput"}}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateManyViewGroups"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"inputs"},"value":{"kind":"Variable","name":{"kind":"Name","value":"inputs"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ViewGroupFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ViewGroupFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ViewGroup"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"isVisible"}},{"kind":"Field","name":{"kind":"Name","value":"fieldValue"}},{"kind":"Field","name":{"kind":"Name","value":"position"}},{"kind":"Field","name":{"kind":"Name","value":"viewId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"deletedAt"}}]}}]} as unknown as DocumentNode; export const UpdateViewDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateView"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateViewInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateView"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}},{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ViewFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ViewFieldFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ViewField"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"fieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"viewId"}},{"kind":"Field","name":{"kind":"Name","value":"isVisible"}},{"kind":"Field","name":{"kind":"Name","value":"position"}},{"kind":"Field","name":{"kind":"Name","value":"size"}},{"kind":"Field","name":{"kind":"Name","value":"aggregateOperation"}},{"kind":"Field","name":{"kind":"Name","value":"viewFieldGroupId"}},{"kind":"Field","name":{"kind":"Name","value":"isOverridden"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"deletedAt"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ViewFieldGroupFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ViewFieldGroup"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"position"}},{"kind":"Field","name":{"kind":"Name","value":"isVisible"}},{"kind":"Field","name":{"kind":"Name","value":"viewId"}},{"kind":"Field","name":{"kind":"Name","value":"isOverridden"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"deletedAt"}},{"kind":"Field","name":{"kind":"Name","value":"viewFields"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ViewFieldFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ViewFilterFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ViewFilter"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"fieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"operand"}},{"kind":"Field","name":{"kind":"Name","value":"value"}},{"kind":"Field","name":{"kind":"Name","value":"viewFilterGroupId"}},{"kind":"Field","name":{"kind":"Name","value":"positionInViewFilterGroup"}},{"kind":"Field","name":{"kind":"Name","value":"subFieldName"}},{"kind":"Field","name":{"kind":"Name","value":"viewId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"deletedAt"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ViewFilterGroupFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ViewFilterGroup"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"parentViewFilterGroupId"}},{"kind":"Field","name":{"kind":"Name","value":"logicalOperator"}},{"kind":"Field","name":{"kind":"Name","value":"positionInViewFilterGroup"}},{"kind":"Field","name":{"kind":"Name","value":"viewId"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ViewSortFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ViewSort"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"fieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"direction"}},{"kind":"Field","name":{"kind":"Name","value":"viewId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"deletedAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ViewGroupFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ViewGroup"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"isVisible"}},{"kind":"Field","name":{"kind":"Name","value":"fieldValue"}},{"kind":"Field","name":{"kind":"Name","value":"position"}},{"kind":"Field","name":{"kind":"Name","value":"viewId"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"deletedAt"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ViewFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"View"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"objectMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"icon"}},{"kind":"Field","name":{"kind":"Name","value":"position"}},{"kind":"Field","name":{"kind":"Name","value":"isCompact"}},{"kind":"Field","name":{"kind":"Name","value":"openRecordIn"}},{"kind":"Field","name":{"kind":"Name","value":"kanbanAggregateOperation"}},{"kind":"Field","name":{"kind":"Name","value":"kanbanAggregateOperationFieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"mainGroupByFieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"shouldHideEmptyGroups"}},{"kind":"Field","name":{"kind":"Name","value":"anyFieldFilterValue"}},{"kind":"Field","name":{"kind":"Name","value":"calendarFieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"calendarLayout"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"createdByUserWorkspaceId"}},{"kind":"Field","name":{"kind":"Name","value":"viewFields"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ViewFieldFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"viewFieldGroups"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ViewFieldGroupFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"viewFilters"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ViewFilterFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"viewFilterGroups"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ViewFilterGroupFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"viewSorts"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ViewSortFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"viewGroups"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ViewGroupFragment"}}]}}]}}]} as unknown as DocumentNode; export const UpdateViewFieldDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateViewField"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateViewFieldInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateViewField"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ViewFieldFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ViewFieldFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ViewField"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"fieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"viewId"}},{"kind":"Field","name":{"kind":"Name","value":"isVisible"}},{"kind":"Field","name":{"kind":"Name","value":"position"}},{"kind":"Field","name":{"kind":"Name","value":"size"}},{"kind":"Field","name":{"kind":"Name","value":"aggregateOperation"}},{"kind":"Field","name":{"kind":"Name","value":"viewFieldGroupId"}},{"kind":"Field","name":{"kind":"Name","value":"isOverridden"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"deletedAt"}}]}}]} as unknown as DocumentNode; export const UpdateViewFieldGroupDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateViewFieldGroup"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateViewFieldGroupInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateViewFieldGroup"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ViewFieldGroupFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ViewFieldFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ViewField"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"fieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"viewId"}},{"kind":"Field","name":{"kind":"Name","value":"isVisible"}},{"kind":"Field","name":{"kind":"Name","value":"position"}},{"kind":"Field","name":{"kind":"Name","value":"size"}},{"kind":"Field","name":{"kind":"Name","value":"aggregateOperation"}},{"kind":"Field","name":{"kind":"Name","value":"viewFieldGroupId"}},{"kind":"Field","name":{"kind":"Name","value":"isOverridden"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"deletedAt"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ViewFieldGroupFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ViewFieldGroup"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"position"}},{"kind":"Field","name":{"kind":"Name","value":"isVisible"}},{"kind":"Field","name":{"kind":"Name","value":"viewId"}},{"kind":"Field","name":{"kind":"Name","value":"isOverridden"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"deletedAt"}},{"kind":"Field","name":{"kind":"Name","value":"viewFields"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ViewFieldFragment"}}]}}]}}]} as unknown as DocumentNode; diff --git a/packages/twenty-front/src/modules/views/graphql/mutations/updateManyViewGroups.ts b/packages/twenty-front/src/modules/views/graphql/mutations/updateManyViewGroups.ts new file mode 100644 index 0000000000..7697e1ab5c --- /dev/null +++ b/packages/twenty-front/src/modules/views/graphql/mutations/updateManyViewGroups.ts @@ -0,0 +1,11 @@ +import { VIEW_GROUP_FRAGMENT } from '@/views/graphql/fragments/viewGroupFragment'; +import { gql } from '@apollo/client'; + +export const UPDATE_MANY_VIEW_GROUPS = gql` + ${VIEW_GROUP_FRAGMENT} + mutation UpdateManyViewGroups($inputs: [UpdateViewGroupInput!]!) { + updateManyViewGroups(inputs: $inputs) { + ...ViewGroupFragment + } + } +`; diff --git a/packages/twenty-front/src/modules/views/hooks/internal/usePerformViewGroupAPIPersist.ts b/packages/twenty-front/src/modules/views/hooks/internal/usePerformViewGroupAPIPersist.ts index ad366f45a6..fdff739098 100644 --- a/packages/twenty-front/src/modules/views/hooks/internal/usePerformViewGroupAPIPersist.ts +++ b/packages/twenty-front/src/modules/views/hooks/internal/usePerformViewGroupAPIPersist.ts @@ -8,43 +8,44 @@ import { t } from '@lingui/core/macro'; import { CrudOperationType } from 'twenty-shared/types'; import { useMutation } from '@apollo/client/react'; import { - type UpdateViewGroupMutationVariables, - UpdateViewGroupDocument, + type UpdateManyViewGroupsMutationVariables, + UpdateManyViewGroupsDocument, } from '~/generated-metadata/graphql'; export const usePerformViewGroupAPIPersist = () => { - const [updateViewGroupMutation] = useMutation(UpdateViewGroupDocument); + const [updateManyViewGroupsMutation] = useMutation( + UpdateManyViewGroupsDocument, + ); const { handleMetadataError } = useMetadataErrorHandler(); const { enqueueErrorSnackBar } = useSnackBar(); const performViewGroupAPIUpdate = useCallback( async ( - updateViewGroupInputs: UpdateViewGroupMutationVariables[], + updateViewGroupInputs: UpdateManyViewGroupsMutationVariables, ): Promise< - MetadataRequestResult< - Awaited>[] - > + MetadataRequestResult + > | null> > => { - if (updateViewGroupInputs.length === 0) { + if ( + !Array.isArray(updateViewGroupInputs.inputs) || + updateViewGroupInputs.inputs.length === 0 + ) { return { status: 'successful', - response: [], + response: null, }; } try { - const results = await Promise.all( - updateViewGroupInputs.map((variables) => - updateViewGroupMutation({ - variables, - }), - ), - ); + const result = await updateManyViewGroupsMutation({ + variables: updateViewGroupInputs, + }); return { status: 'successful', - response: results, + response: result, }; } catch (error) { if (CombinedGraphQLErrors.is(error)) { @@ -62,7 +63,7 @@ export const usePerformViewGroupAPIPersist = () => { }; } }, - [updateViewGroupMutation, handleMetadataError, enqueueErrorSnackBar], + [updateManyViewGroupsMutation, handleMetadataError, enqueueErrorSnackBar], ); return { diff --git a/packages/twenty-front/src/modules/views/hooks/useSaveCurrentViewGroups.ts b/packages/twenty-front/src/modules/views/hooks/useSaveCurrentViewGroups.ts index c4a78d2ba5..fc3f349eb5 100644 --- a/packages/twenty-front/src/modules/views/hooks/useSaveCurrentViewGroups.ts +++ b/packages/twenty-front/src/modules/views/hooks/useSaveCurrentViewGroups.ts @@ -67,9 +67,9 @@ export const useSaveCurrentViewGroups = () => { return; } - await performViewGroupAPIUpdate([ - { - input: { + await performViewGroupAPIUpdate({ + inputs: [ + { id: existingField.id, update: { isVisible: viewGroupToSave.isVisible, @@ -77,8 +77,8 @@ export const useSaveCurrentViewGroups = () => { fieldValue: viewGroupToSave.fieldValue, }, }, - }, - ]); + ], + }); }, [ store, @@ -109,7 +109,7 @@ export const useSaveCurrentViewGroups = () => { const currentViewGroups = view.viewGroups; - const viewGroupsToUpdate = viewGroupsToSave + const viewGroupInputsToUpdate = viewGroupsToSave .map((viewGroupToSave) => { const existingField = currentViewGroups.find( (currentViewGroup) => @@ -136,13 +136,11 @@ export const useSaveCurrentViewGroups = () => { } return { - input: { - id: existingField.id, - update: { - isVisible: viewGroupToSave.isVisible, - position: viewGroupToSave.position, - fieldValue: viewGroupToSave.fieldValue, - }, + id: existingField.id, + update: { + isVisible: viewGroupToSave.isVisible, + position: viewGroupToSave.position, + fieldValue: viewGroupToSave.fieldValue, }, }; }) @@ -152,7 +150,7 @@ export const useSaveCurrentViewGroups = () => { throw new Error('mainGroupByFieldMetadataId is required'); } - await performViewGroupAPIUpdate(viewGroupsToUpdate); + await performViewGroupAPIUpdate({ inputs: viewGroupInputsToUpdate }); }, [ store, 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 f9b5e05167..2b93c17352 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 @@ -84,6 +84,19 @@ export class ViewGroupResolver { }); } + @Mutation(() => [ViewGroupDTO]) + @UseGuards(UpdateViewGroupPermissionGuard) + async updateManyViewGroups( + @Args('inputs', { type: () => [UpdateViewGroupInput] }) + updateViewGroupInputs: UpdateViewGroupInput[], + @AuthWorkspace() { id: workspaceId }: WorkspaceEntity, + ): Promise { + return await this.viewGroupService.updateMany({ + updateViewGroupInputs, + workspaceId, + }); + } + @Mutation(() => ViewGroupDTO) @UseGuards(DeleteViewGroupPermissionGuard) async deleteViewGroup( diff --git a/packages/twenty-server/src/engine/metadata-modules/view-group/services/view-group.service.ts b/packages/twenty-server/src/engine/metadata-modules/view-group/services/view-group.service.ts index 3c134a6e7d..e2f1570781 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view-group/services/view-group.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/view-group/services/view-group.service.ts @@ -7,6 +7,7 @@ import { IsNull, Repository } from 'typeorm'; import { ApplicationService } from 'src/engine/core-modules/application/application.service'; import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util'; +import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util'; import { findFlatEntityByUniversalIdentifierOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier-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'; @@ -152,6 +153,32 @@ export class ViewGroupService { workspaceId: string; updateViewGroupInput: UpdateViewGroupInput; }): Promise { + const [updatedViewGroup] = await this.updateMany({ + updateViewGroupInputs: [updateViewGroupInput], + workspaceId, + }); + + if (!isDefined(updatedViewGroup)) { + throw new ViewGroupException( + 'Failed to update view group', + ViewGroupExceptionCode.INVALID_VIEW_GROUP_DATA, + ); + } + + return updatedViewGroup; + } + + async updateMany({ + updateViewGroupInputs, + workspaceId, + }: { + updateViewGroupInputs: UpdateViewGroupInput[]; + workspaceId: string; + }): Promise { + if (updateViewGroupInputs.length === 0) { + return []; + } + const { workspaceCustomFlatApplication } = await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow( { @@ -167,11 +194,13 @@ export class ViewGroupService { }, ); - const optimisticallyUpdatedFlatViewGroup = - fromUpdateViewGroupInputToFlatViewGroupToUpdateOrThrow({ - flatViewGroupMaps: existingFlatViewGroupMaps, - updateViewGroupInput, - }); + const flatViewGroupsToUpdate = updateViewGroupInputs.map( + (updateViewGroupInput) => + fromUpdateViewGroupInputToFlatViewGroupToUpdateOrThrow({ + flatViewGroupMaps: existingFlatViewGroupMaps, + updateViewGroupInput, + }), + ); const validateAndBuildResult = await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration( @@ -180,7 +209,7 @@ export class ViewGroupService { viewGroup: { flatEntityToCreate: [], flatEntityToDelete: [], - flatEntityToUpdate: [optimisticallyUpdatedFlatViewGroup], + flatEntityToUpdate: flatViewGroupsToUpdate, }, }, workspaceId, @@ -193,7 +222,7 @@ export class ViewGroupService { if (validateAndBuildResult.status === 'fail') { throw new WorkspaceMigrationBuilderException( validateAndBuildResult, - 'Multiple validation errors occurred while updating view group', + 'Multiple validation errors occurred while updating view groups', ); } @@ -205,12 +234,13 @@ export class ViewGroupService { }, ); - return fromFlatViewGroupToViewGroupDto( - findFlatEntityByUniversalIdentifierOrThrow({ - universalIdentifier: - optimisticallyUpdatedFlatViewGroup.universalIdentifier, - flatEntityMaps: recomputedExistingFlatViewGroupMaps, - }), + return updateViewGroupInputs.map(({ id }) => + fromFlatViewGroupToViewGroupDto( + findFlatEntityByIdInFlatEntityMapsOrThrow({ + flatEntityId: id, + flatEntityMaps: recomputedExistingFlatViewGroupMaps, + }), + ), ); } diff --git a/packages/twenty-server/test/integration/graphql/suites/view/view-group/successful-update-many-view-groups-v2.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/view/view-group/successful-update-many-view-groups-v2.integration-spec.ts new file mode 100644 index 0000000000..02450ff6b1 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/view/view-group/successful-update-many-view-groups-v2.integration-spec.ts @@ -0,0 +1,235 @@ +import { createOneSelectFieldMetadataForIntegrationTests } from 'test/integration/metadata/suites/field-metadata/utils/create-one-select-field-metadata-for-integration-tests.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 { createManyViewGroups } from 'test/integration/metadata/suites/view-group/utils/create-many-view-groups.util'; +import { deleteOneViewGroup } from 'test/integration/metadata/suites/view-group/utils/delete-one-view-group.util'; +import { destroyOneViewGroup } from 'test/integration/metadata/suites/view-group/utils/destroy-one-view-group.util'; +import { updateManyViewGroups } from 'test/integration/metadata/suites/view-group/utils/update-many-view-groups.util'; +import { createOneView } from 'test/integration/metadata/suites/view/utils/create-one-view.util'; +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 Update Many Operations - v2', () => { + let testSetup: { + testViewId: string; + testObjectMetadataId: string; + }; + let createdViewGroupIds: string[] = []; + + beforeAll(async () => { + const { + data: { + createOneObject: { id: objectMetadataId }, + }, + } = await createOneObjectMetadata({ + expectToFail: false, + input: { + nameSingular: 'myUpdateManyGroupTestObjectV2', + namePlural: 'myUpdateManyGroupTestObjectsV2', + labelSingular: 'My Update Many Group Test Object v2', + labelPlural: 'My Update Many Group Test Objects v2', + icon: 'Icon123', + }, + }); + + const { selectFieldMetadataId } = + await createOneSelectFieldMetadataForIntegrationTests({ + input: { + objectMetadataId, + }, + }); + + const { + data: { + createView: { id: testViewId }, + }, + } = await createOneView({ + input: { + icon: 'icon123', + objectMetadataId, + name: 'TestViewForUpdateManyGroups', + mainGroupByFieldMetadataId: selectFieldMetadataId, + }, + expectToFail: false, + }); + + testSetup = { + testViewId, + testObjectMetadataId: objectMetadataId, + }; + }); + + 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: { deleteViewGroup }, + } = await deleteOneViewGroup({ + expectToFail: false, + input: { + id: viewGroupId, + }, + }); + + expect(deleteViewGroup.deletedAt).not.toBeNull(); + await destroyOneViewGroup({ + expectToFail: false, + input: { + id: viewGroupId, + }, + }); + } + } + createdViewGroupIds = []; + }); + + it('should batch-update positions of multiple view groups at once', async () => { + const createInputs: CreateViewGroupInput[] = [ + { + viewId: testSetup.testViewId, + position: 0, + isVisible: true, + fieldValue: 'Group A', + }, + { + viewId: testSetup.testViewId, + position: 1, + isVisible: true, + fieldValue: 'Group B', + }, + { + viewId: testSetup.testViewId, + position: 2, + isVisible: true, + fieldValue: 'Group C', + }, + ]; + + const { + data: { createManyViewGroups: createdGroups }, + } = await createManyViewGroups({ + inputs: createInputs, + expectToFail: false, + }); + + createdViewGroupIds = createdGroups.map( + (viewGroup: { id: string }) => viewGroup.id, + ); + + const { + data: { updateManyViewGroups: updatedGroups }, + errors, + } = await updateManyViewGroups({ + inputs: [ + { id: createdGroups[0].id, update: { position: 2 } }, + { id: createdGroups[1].id, update: { position: 0 } }, + { id: createdGroups[2].id, update: { position: 1 } }, + ], + expectToFail: false, + }); + + expect(errors).toBeUndefined(); + expect(updatedGroups).toBeDefined(); + expect(updatedGroups).toHaveLength(3); + + expect(updatedGroups[0]).toMatchObject({ + id: createdGroups[0].id, + position: 2, + }); + expect(updatedGroups[1]).toMatchObject({ + id: createdGroups[1].id, + position: 0, + }); + expect(updatedGroups[2]).toMatchObject({ + id: createdGroups[2].id, + position: 1, + }); + }); + + it('should batch-update visibility of multiple view groups at once', async () => { + const createInputs: CreateViewGroupInput[] = [ + { + viewId: testSetup.testViewId, + position: 0, + isVisible: true, + fieldValue: 'Visible Group', + }, + { + viewId: testSetup.testViewId, + position: 1, + isVisible: true, + fieldValue: 'To Be Hidden Group', + }, + ]; + + const { + data: { createManyViewGroups: createdGroups }, + } = await createManyViewGroups({ + inputs: createInputs, + expectToFail: false, + }); + + createdViewGroupIds = createdGroups.map( + (viewGroup: { id: string }) => viewGroup.id, + ); + + const { + data: { updateManyViewGroups: updatedGroups }, + errors, + } = await updateManyViewGroups({ + inputs: [ + { id: createdGroups[0].id, update: { isVisible: false } }, + { + id: createdGroups[1].id, + update: { isVisible: false, position: 5 }, + }, + ], + expectToFail: false, + }); + + expect(errors).toBeUndefined(); + expect(updatedGroups).toBeDefined(); + expect(updatedGroups).toHaveLength(2); + + expect(updatedGroups[0]).toMatchObject({ + id: createdGroups[0].id, + isVisible: false, + }); + expect(updatedGroups[1]).toMatchObject({ + id: createdGroups[1].id, + isVisible: false, + position: 5, + }); + }); + + it('should return empty array for empty inputs', async () => { + const { + data: { updateManyViewGroups: updatedGroups }, + errors, + } = await updateManyViewGroups({ + inputs: [], + expectToFail: false, + }); + + expect(errors).toBeUndefined(); + expect(updatedGroups).toBeDefined(); + expect(updatedGroups).toHaveLength(0); + }); +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/view-group/utils/update-many-view-groups-query-factory.util.ts b/packages/twenty-server/test/integration/metadata/suites/view-group/utils/update-many-view-groups-query-factory.util.ts new file mode 100644 index 0000000000..ed2a262758 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/view-group/utils/update-many-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 UpdateViewGroupInput } from 'src/engine/metadata-modules/view-group/dtos/inputs/update-view-group.input'; + +export const updateManyViewGroupsQueryFactory = ({ + gqlFields = VIEW_GROUP_GQL_FIELDS, + inputs, +}: { + gqlFields?: string; + inputs: UpdateViewGroupInput[]; +}) => ({ + query: gql` + mutation UpdateManyViewGroups($inputs: [UpdateViewGroupInput!]!) { + updateManyViewGroups(inputs: $inputs) { + ${gqlFields} + } + } + `, + variables: { + inputs, + }, +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/view-group/utils/update-many-view-groups.util.ts b/packages/twenty-server/test/integration/metadata/suites/view-group/utils/update-many-view-groups.util.ts new file mode 100644 index 0000000000..cc70fee379 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/view-group/utils/update-many-view-groups.util.ts @@ -0,0 +1,45 @@ +import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util'; +import { updateManyViewGroupsQueryFactory } from 'test/integration/metadata/suites/view-group/utils/update-many-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 UpdateViewGroupInput } from 'src/engine/metadata-modules/view-group/dtos/inputs/update-view-group.input'; +import { type ViewGroupEntity } from 'src/engine/metadata-modules/view-group/entities/view-group.entity'; + +export const updateManyViewGroups = async ({ + inputs, + gqlFields, + expectToFail, +}: { + inputs: UpdateViewGroupInput[]; + gqlFields?: string; + expectToFail?: boolean; +}): CommonResponseBody<{ + updateManyViewGroups: ViewGroupEntity[]; +}> => { + const graphqlOperation = updateManyViewGroupsQueryFactory({ + inputs, + gqlFields, + }); + + const response = await makeMetadataAPIRequest(graphqlOperation); + + if (expectToFail === true) { + warnIfNoErrorButExpectedToFail({ + response, + errorMessage: + 'View Groups batch update should have failed but did not', + }); + } + + if (expectToFail === false) { + warnIfErrorButNotExpectedToFail({ + response, + errorMessage: + 'View Groups batch update has failed but should not', + }); + } + + return { data: response.body.data, errors: response.body.errors }; +};