From 9a0295fd3d6e12fa72a7e95a0049062c1b9d7d07 Mon Sep 17 00:00:00 2001
From: Copilot <198982749+Copilot@users.noreply.github.com>
Date: Mon, 23 Feb 2026 19:25:47 +0100
Subject: [PATCH] feat: add atomic `upsertFieldsWidget` mutation to replace
multiple view field group/field API calls (#18137)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The `useSaveFieldsWidgetGroups` hook was making multiple sequential API
calls per widget (create groups, delete groups, update groups, update
fields) — non-atomic, chatty, and with heavy diff computation on the
frontend.
## Backend
- **New input DTOs**: `UpsertFieldsWidgetInput` /
`UpsertFieldsWidgetGroupInput` / `UpsertFieldsWidgetFieldInput` — caller
passes the full desired state of groups + fields for a widget
- **`FieldsWidgetUpsertService`**: looks up widget → resolves `viewId`,
diffs against existing flat entity maps, builds optimistic group maps so
newly-created groups can be referenced by field updates in the same
pass, then runs creates/updates/deletes in a single
`validateBuildAndRunWorkspaceMigration` call
- **`upsertFieldsWidget` mutation** added to `ViewFieldGroupResolver`;
new `FIELDS_WIDGET_NOT_FOUND` exception code added and wired into the
GraphQL exception filter
## Frontend
- New `UPSERT_FIELDS_WIDGET` GQL document
- `useSaveFieldsWidgetGroups` replaces all per-operation calls with a
single mutation per widget, passing the full draft state — diff logic
moves entirely to the backend
```graphql
mutation UpsertFieldsWidget($input: UpsertFieldsWidgetInput!) {
upsertFieldsWidget(input: $input) {
id
name
position
isVisible
viewId
viewFields { id isVisible position ... }
}
}
```
Widget IDs are collected from both draft **and** persisted state so
widgets whose draft groups were cleared still trigger deletion of their
server-side groups.
Original prompt
Start implementation
The user has attached the following file paths as relevant context:
- CLAUDE.md
[Chronological Review: The conversation began with the user requesting a
review of how updates on views are stored in the frontend of record page
layouts. The user expressed a desire to migrate heavy computations and
multiple requests from the frontend to the backend. They proposed
creating a custom upsertFieldsWidget mutation to handle updates
atomically. The conversation has focused on this migration strategy and
its implementation.]
[Intent Mapping: The user explicitly requested to "check how we store
updates on views in the frontend of record page layouts" and indicated a
need to "migrate this task to the backend" by creating a custom mutation
for atomic updates.]
[Technical Inventory: The conversation involves the following technical
concepts:
- Frontend and backend architecture for handling page layout updates.
- Custom GraphQL mutation (upsertFieldsWidget) for atomic updates.
- The file in focus is page-layout-update.service.ts, which is likely
responsible for handling updates related to page layouts in the
backend.]
[Code Archaeology: The specific file being worked on is
/Users/devessier/Web/twenty/packages/twenty-server/src/engine/metadata-modules/page-layout/services/page-layout-update.service.ts.
The user is likely looking to modify or enhance this service to
accommodate the new mutation for handling updates.]
[Progress Assessment: The user has identified the need for a backend
migration strategy but has not yet implemented any changes. The focus is
on planning the transition from frontend to backend processing.]
[Context Validation: All critical information for continuation is
captured, including the user's intent to migrate computations to the
backend and the specific file being modified.]
[Recent Commands Analysis: The last agent commands involved checking the
current file context and the user's request to summarize the
conversation. No specific tool results were generated as the focus was
on summarizing the conversation history. The agent was actively working
on summarizing the conversation when the token budget was exceeded,
which was triggered by the user's request for a summary.]
1. Conversation Overview:
- Primary Objectives: The user requested to "check how we store updates
on views in the frontend of record page layouts" and expressed a desire
to "migrate this task to the backend" by creating a custom
upsertFieldsWidget mutation for atomic updates.
- Session Context: The conversation has focused on the need to shift
heavy computations from the frontend to the backend, with an emphasis on
implementing a new mutation to streamline updates.
- User Intent Evolution: The user's needs have remained consistent,
focusing on backend migration for efficiency and atomicity in updates.
2. Technical Foundation:
- Core Technology: The conversation involves a backend service likely
built with Node.js and TypeScript, as indicated by the file path.
- Framework/Library: The use of GraphQL for handling mutations is
implied through the mention of a custom mutation.
- Architectural Pattern: The user is looking to implement a
service-oriented architecture where the backend handles complex
computations instead of the frontend.
- Environment Detail: The current working file is located in a server
package of the twenty repository.
3. Codebase Status:
- File Name: page-layout-update.service.ts
- Purpose: This file is responsible for managing updates related to page
layouts in the backend.
- Current State: The user is considering modifications to implement a
new mutation for handling updates.
- Key Code Segments: Specific functions or classes have not been
detailed yet, as the focus is on planning changes.
- Dependencies: This service likely interacts with other components in
the metadata-modules related to page layouts.
4. Problem Resolution:
- Issues Encountered: The current challenge is the inefficiency of
handling updates in the frontend.
- Solutions Implemented: The proposed solution is to create a custom
mutation to handle updates atomically in the backend.
- Debugging Context: No ongoing troubleshooting efforts have been
mentioned yet.
- Lessons Learned: The need for backend processing to improve
performance has been highlighted.
5. Progress Tracking:
- Completed Tasks: No tasks have been completed yet; the user is in the
planning phase.
- Partially Complete Work: The user is preparing to implement a new
mutation for backend updates.
- Validated Outcomes: No features have been confirmed working through
testing at this stage.
6. Active Work State:
- Current Focus: The user is focused on modifying the
page-layout-update.service.ts to implement the new mutation.
- Recent Context: The last few exchanges involved discussing the
migration of update tasks from the frontend to the backend.
- Working Code: No specific code snippets have been modified yet, as the
co...
Created from [VS
Code](https://code.visualstudio.com/docs/copilot/copilot-coding-agent).
---
💡 You can make Copilot smarter by setting up custom instructions,
customizing its development environment and configuring Model Context
Protocol (MCP) servers. Learn more [Copilot coding agent
tips](https://gh.io/copilot-coding-agent-tips) in the docs.
---------
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Devessier <29370468+Devessier@users.noreply.github.com>
Co-authored-by: Baptiste Devessier
---
.../src/generated-metadata/graphql.ts | 71 +++
.../graphql/mutations/upsertFieldsWidget.ts | 11 +
.../hooks/useSaveFieldsWidgetGroups.ts | 171 ++----
.../upsert-fields-widget-field.input.ts | 21 +
.../upsert-fields-widget-group.input.ts | 40 ++
.../dtos/inputs/upsert-fields-widget.input.ts | 44 ++
.../exceptions/view-field-group.exception.ts | 1 +
.../resolvers/view-field-group.resolver.ts | 21 +-
.../services/fields-widget-upsert.service.ts | 444 +++++++++++++++
.../view-field-group.module.ts | 7 +-
.../view-field/dtos/view-field.dto.ts | 3 +
...view-graphql-api-exception-handler.util.ts | 2 +
.../constants/view-gql-fields.constants.ts | 1 +
.../upsert-fields-widget.integration-spec.ts | 504 ++++++++++++++++++
...upsert-fields-widget-query-factory.util.ts | 23 +
.../utils/upsert-fields-widget.util.ts | 43 ++
16 files changed, 1287 insertions(+), 120 deletions(-)
create mode 100644 packages/twenty-front/src/modules/page-layout/graphql/mutations/upsertFieldsWidget.ts
create mode 100644 packages/twenty-server/src/engine/metadata-modules/view-field-group/dtos/inputs/upsert-fields-widget-field.input.ts
create mode 100644 packages/twenty-server/src/engine/metadata-modules/view-field-group/dtos/inputs/upsert-fields-widget-group.input.ts
create mode 100644 packages/twenty-server/src/engine/metadata-modules/view-field-group/dtos/inputs/upsert-fields-widget.input.ts
create mode 100644 packages/twenty-server/src/engine/metadata-modules/view-field-group/services/fields-widget-upsert.service.ts
create mode 100644 packages/twenty-server/test/integration/metadata/suites/view-field-group/upsert-fields-widget.integration-spec.ts
create mode 100644 packages/twenty-server/test/integration/metadata/suites/view-field-group/utils/upsert-fields-widget-query-factory.util.ts
create mode 100644 packages/twenty-server/test/integration/metadata/suites/view-field-group/utils/upsert-fields-widget.util.ts
diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts
index f0efc1d2bc..7865106f1e 100644
--- a/packages/twenty-front/src/generated-metadata/graphql.ts
+++ b/packages/twenty-front/src/generated-metadata/graphql.ts
@@ -876,6 +876,7 @@ export type CoreViewField = {
position: Scalars['Float'];
size: Scalars['Float'];
updatedAt: Scalars['DateTime'];
+ viewFieldGroupId?: Maybe;
viewId: Scalars['UUID'];
workspaceId: Scalars['UUID'];
};
@@ -2353,6 +2354,7 @@ export type Mutation = {
uploadWorkspaceMemberProfilePicture: FileWithSignedUrl;
uploadWorkspaceMemberProfilePictureLegacy: SignedFile;
upsertFieldPermissions: Array;
+ upsertFieldsWidget: CoreView;
upsertObjectPermissions: Array;
upsertPermissionFlags: Array;
upsertRowLevelPermissionPredicates: UpsertRowLevelPermissionPredicatesResult;
@@ -3207,6 +3209,11 @@ export type MutationUpsertFieldPermissionsArgs = {
};
+export type MutationUpsertFieldsWidgetArgs = {
+ input: UpsertFieldsWidgetInput;
+};
+
+
export type MutationUpsertObjectPermissionsArgs = {
upsertObjectPermissionsInput: UpsertObjectPermissionsInput;
};
@@ -4856,6 +4863,30 @@ export type UpsertFieldPermissionsInput = {
roleId: Scalars['UUID'];
};
+export type UpsertFieldsWidgetFieldInput = {
+ isVisible: Scalars['Boolean'];
+ position: Scalars['Float'];
+ /** The id of the view field */
+ viewFieldId: Scalars['UUID'];
+};
+
+export type UpsertFieldsWidgetGroupInput = {
+ fields: Array;
+ id: Scalars['UUID'];
+ isVisible: Scalars['Boolean'];
+ name: Scalars['String'];
+ position: Scalars['Float'];
+};
+
+export type UpsertFieldsWidgetInput = {
+ /** The ungrouped fields to upsert. When provided, all existing groups are deleted and fields are detached from groups. Mutually exclusive with "groups". */
+ fields?: InputMaybe>;
+ /** The groups (with nested fields) to upsert. Mutually exclusive with "fields". */
+ groups?: InputMaybe>;
+ /** The id of the fields widget whose groups and fields to upsert */
+ widgetId: Scalars['UUID'];
+};
+
export type UpsertObjectPermissionsInput = {
objectPermissions: Array;
roleId: Scalars['UUID'];
@@ -6006,6 +6037,13 @@ export type UpdatePageLayoutWithTabsAndWidgetsMutationVariables = Exact<{
export type UpdatePageLayoutWithTabsAndWidgetsMutation = { __typename?: 'Mutation', updatePageLayoutWithTabsAndWidgets: { __typename?: 'PageLayout', id: string, name: string, type: PageLayoutType, objectMetadataId?: string | null, defaultTabToFocusOnMobileAndSidePanelId?: string | null, createdAt: string, updatedAt: string, deletedAt?: string | null, tabs?: Array<{ __typename?: 'PageLayoutTab', id: string, applicationId: string, title: string, icon?: string | null, position: number, layoutMode?: PageLayoutTabLayoutMode | null, pageLayoutId: string, createdAt: string, updatedAt: string, widgets?: Array<{ __typename?: 'PageLayoutWidget', id: string, title: string, type: WidgetType, objectMetadataId?: string | null, createdAt: string, updatedAt: string, deletedAt?: string | null, pageLayoutTabId: string, gridPosition: { __typename?: 'GridPosition', column: number, columnSpan: number, row: number, rowSpan: number }, position?: { __typename?: 'PageLayoutWidgetCanvasPosition', layoutMode: PageLayoutTabLayoutMode } | { __typename?: 'PageLayoutWidgetGridPosition', layoutMode: PageLayoutTabLayoutMode, row: number, column: number, rowSpan: number, columnSpan: number } | { __typename?: 'PageLayoutWidgetVerticalListPosition', layoutMode: PageLayoutTabLayoutMode, index: number } | null, configuration: { __typename?: 'AggregateChartConfiguration', configurationType: WidgetConfigurationType, aggregateFieldMetadataId: string, aggregateOperation: AggregateOperations, label?: string | null, displayDataLabel?: boolean | null, format?: string | null, description?: string | null, filter?: any | null, prefix?: string | null, suffix?: string | null, timezone?: string | null, firstDayOfTheWeek?: number | null, ratioAggregateConfig?: { __typename?: 'RatioAggregateConfig', fieldMetadataId: string, optionValue: string } | null } | { __typename?: 'BarChartConfiguration', configurationType: WidgetConfigurationType, aggregateFieldMetadataId: string, aggregateOperation: AggregateOperations, primaryAxisGroupByFieldMetadataId: string, primaryAxisGroupBySubFieldName?: string | null, primaryAxisDateGranularity?: ObjectRecordGroupByDateGranularity | null, primaryAxisOrderBy?: GraphOrderBy | null, primaryAxisManualSortOrder?: Array | null, secondaryAxisGroupByFieldMetadataId?: string | null, secondaryAxisGroupBySubFieldName?: string | null, secondaryAxisGroupByDateGranularity?: ObjectRecordGroupByDateGranularity | null, secondaryAxisOrderBy?: GraphOrderBy | null, secondaryAxisManualSortOrder?: Array | null, omitNullValues?: boolean | null, axisNameDisplay?: AxisNameDisplay | null, displayDataLabel?: boolean | null, displayLegend?: boolean | null, rangeMin?: number | null, rangeMax?: number | null, color?: string | null, description?: string | null, filter?: any | null, groupMode?: BarChartGroupMode | null, layout: BarChartLayout, isCumulative?: boolean | null, splitMultiValueFields?: boolean | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'CalendarConfiguration', configurationType: WidgetConfigurationType } | { __typename?: 'EmailsConfiguration', configurationType: WidgetConfigurationType } | { __typename?: 'FieldConfiguration', configurationType: WidgetConfigurationType } | { __typename?: 'FieldRichTextConfiguration', configurationType: WidgetConfigurationType } | { __typename?: 'FieldsConfiguration', configurationType: WidgetConfigurationType, viewId?: string | null } | { __typename?: 'FilesConfiguration', configurationType: WidgetConfigurationType } | { __typename?: 'FrontComponentConfiguration', configurationType: WidgetConfigurationType, frontComponentId: string } | { __typename?: 'GaugeChartConfiguration', configurationType: WidgetConfigurationType, aggregateFieldMetadataId: string, aggregateOperation: AggregateOperations, displayDataLabel?: boolean | null, color?: string | null, description?: string | null, filter?: any | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'IframeConfiguration', configurationType: WidgetConfigurationType, url?: string | null } | { __typename?: 'LineChartConfiguration', configurationType: WidgetConfigurationType, aggregateFieldMetadataId: string, aggregateOperation: AggregateOperations, primaryAxisGroupByFieldMetadataId: string, primaryAxisGroupBySubFieldName?: string | null, primaryAxisDateGranularity?: ObjectRecordGroupByDateGranularity | null, primaryAxisOrderBy?: GraphOrderBy | null, primaryAxisManualSortOrder?: Array | null, secondaryAxisGroupByFieldMetadataId?: string | null, secondaryAxisGroupBySubFieldName?: string | null, secondaryAxisGroupByDateGranularity?: ObjectRecordGroupByDateGranularity | null, secondaryAxisOrderBy?: GraphOrderBy | null, secondaryAxisManualSortOrder?: Array | null, omitNullValues?: boolean | null, axisNameDisplay?: AxisNameDisplay | null, displayDataLabel?: boolean | null, displayLegend?: boolean | null, rangeMin?: number | null, rangeMax?: number | null, color?: string | null, description?: string | null, filter?: any | null, isStacked?: boolean | null, isCumulative?: boolean | null, splitMultiValueFields?: boolean | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'NotesConfiguration', configurationType: WidgetConfigurationType } | { __typename?: 'PieChartConfiguration', configurationType: WidgetConfigurationType, groupByFieldMetadataId: string, aggregateFieldMetadataId: string, aggregateOperation: AggregateOperations, groupBySubFieldName?: string | null, dateGranularity?: ObjectRecordGroupByDateGranularity | null, orderBy?: GraphOrderBy | null, manualSortOrder?: Array | null, displayDataLabel?: boolean | null, showCenterMetric?: boolean | null, displayLegend?: boolean | null, hideEmptyCategory?: boolean | null, splitMultiValueFields?: boolean | null, color?: string | null, description?: string | null, filter?: any | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'StandaloneRichTextConfiguration', configurationType: WidgetConfigurationType, body: { __typename?: 'RichTextV2Body', blocknote?: string | null, markdown?: string | null } } | { __typename?: 'TasksConfiguration', configurationType: WidgetConfigurationType } | { __typename?: 'TimelineConfiguration', configurationType: WidgetConfigurationType } | { __typename?: 'ViewConfiguration', configurationType: WidgetConfigurationType } | { __typename?: 'WorkflowConfiguration', configurationType: WidgetConfigurationType } | { __typename?: 'WorkflowRunConfiguration', configurationType: WidgetConfigurationType } | { __typename?: 'WorkflowVersionConfiguration', configurationType: WidgetConfigurationType } }> | null }> | null } };
+export type UpsertFieldsWidgetMutationVariables = Exact<{
+ input: UpsertFieldsWidgetInput;
+}>;
+
+
+export type UpsertFieldsWidgetMutation = { __typename?: 'Mutation', upsertFieldsWidget: { __typename?: 'CoreView', id: string, name: string, objectMetadataId: string, type: ViewType, key?: ViewKey | null, icon: string, position: number, isCompact: boolean, openRecordIn: ViewOpenRecordIn, kanbanAggregateOperation?: AggregateOperations | null, kanbanAggregateOperationFieldMetadataId?: string | null, mainGroupByFieldMetadataId?: string | null, shouldHideEmptyGroups: boolean, anyFieldFilterValue?: string | null, calendarFieldMetadataId?: string | null, calendarLayout?: ViewCalendarLayout | null, visibility: ViewVisibility, createdByUserWorkspaceId?: string | null, viewFields: 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 }>, viewFieldGroups: Array<{ __typename?: 'CoreViewFieldGroup', id: string, name: string, position: number, isVisible: boolean, viewId: string, createdAt: string, updatedAt: string, deletedAt?: string | null, viewFields: 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 }> }>, viewFilters: Array<{ __typename?: 'CoreViewFilter', id: string, fieldMetadataId: string, operand: ViewFilterOperand, value: any, viewFilterGroupId?: string | null, positionInViewFilterGroup?: number | null, subFieldName?: string | null, viewId: string, createdAt: string, updatedAt: string, deletedAt?: string | null }>, viewFilterGroups: Array<{ __typename?: 'CoreViewFilterGroup', id: string, parentViewFilterGroupId?: string | null, logicalOperator: ViewFilterGroupLogicalOperator, positionInViewFilterGroup?: number | null, viewId: string }>, viewSorts: Array<{ __typename?: 'CoreViewSort', id: string, fieldMetadataId: string, direction: ViewSortDirection, viewId: string }>, viewGroups: Array<{ __typename?: 'CoreViewGroup', id: string, isVisible: boolean, fieldValue: string, position: number, viewId: string, createdAt: string, updatedAt: string, deletedAt?: string | null }> } };
+
export type FindAllRecordPageLayoutsQueryVariables = Exact<{ [key: string]: never; }>;
@@ -11677,6 +11715,39 @@ export function useUpdatePageLayoutWithTabsAndWidgetsMutation(baseOptions?: Apol
export type UpdatePageLayoutWithTabsAndWidgetsMutationHookResult = ReturnType;
export type UpdatePageLayoutWithTabsAndWidgetsMutationResult = Apollo.MutationResult;
export type UpdatePageLayoutWithTabsAndWidgetsMutationOptions = Apollo.BaseMutationOptions;
+export const UpsertFieldsWidgetDocument = gql`
+ mutation UpsertFieldsWidget($input: UpsertFieldsWidgetInput!) {
+ upsertFieldsWidget(input: $input) {
+ ...ViewFragment
+ }
+}
+ ${ViewFragmentFragmentDoc}`;
+export type UpsertFieldsWidgetMutationFn = Apollo.MutationFunction;
+
+/**
+ * __useUpsertFieldsWidgetMutation__
+ *
+ * To run a mutation, you first call `useUpsertFieldsWidgetMutation` within a React component and pass it any options that fit your needs.
+ * When your component renders, `useUpsertFieldsWidgetMutation` 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 [upsertFieldsWidgetMutation, { data, loading, error }] = useUpsertFieldsWidgetMutation({
+ * variables: {
+ * input: // value for 'input'
+ * },
+ * });
+ */
+export function useUpsertFieldsWidgetMutation(baseOptions?: Apollo.MutationHookOptions) {
+ const options = {...defaultOptions, ...baseOptions}
+ return Apollo.useMutation(UpsertFieldsWidgetDocument, options);
+ }
+export type UpsertFieldsWidgetMutationHookResult = ReturnType;
+export type UpsertFieldsWidgetMutationResult = Apollo.MutationResult;
+export type UpsertFieldsWidgetMutationOptions = Apollo.BaseMutationOptions;
export const FindAllRecordPageLayoutsDocument = gql`
query FindAllRecordPageLayouts {
getPageLayouts(pageLayoutType: RECORD_PAGE) {
diff --git a/packages/twenty-front/src/modules/page-layout/graphql/mutations/upsertFieldsWidget.ts b/packages/twenty-front/src/modules/page-layout/graphql/mutations/upsertFieldsWidget.ts
new file mode 100644
index 0000000000..af4ef2ab5a
--- /dev/null
+++ b/packages/twenty-front/src/modules/page-layout/graphql/mutations/upsertFieldsWidget.ts
@@ -0,0 +1,11 @@
+import { VIEW_FRAGMENT } from '@/views/graphql/fragments/viewFragment';
+import { gql } from '@apollo/client';
+
+export const UPSERT_FIELDS_WIDGET = gql`
+ ${VIEW_FRAGMENT}
+ mutation UpsertFieldsWidget($input: UpsertFieldsWidgetInput!) {
+ upsertFieldsWidget(input: $input) {
+ ...ViewFragment
+ }
+ }
+`;
diff --git a/packages/twenty-front/src/modules/page-layout/hooks/useSaveFieldsWidgetGroups.ts b/packages/twenty-front/src/modules/page-layout/hooks/useSaveFieldsWidgetGroups.ts
index c951b18f33..e032c34f54 100644
--- a/packages/twenty-front/src/modules/page-layout/hooks/useSaveFieldsWidgetGroups.ts
+++ b/packages/twenty-front/src/modules/page-layout/hooks/useSaveFieldsWidgetGroups.ts
@@ -1,18 +1,36 @@
+import { UPSERT_FIELDS_WIDGET } from '@/page-layout/graphql/mutations/upsertFieldsWidget';
import { fieldsWidgetGroupsDraftComponentState } from '@/page-layout/states/fieldsWidgetGroupsDraftComponentState';
import { fieldsWidgetGroupsPersistedComponentState } from '@/page-layout/states/fieldsWidgetGroupsPersistedComponentState';
-import { pageLayoutPersistedComponentState } from '@/page-layout/states/pageLayoutPersistedComponentState';
-import { computeFieldsWidgetFieldDiff } from '@/page-layout/utils/computeFieldsWidgetFieldDiff';
-import { computeFieldsWidgetGroupDiff } from '@/page-layout/utils/computeFieldsWidgetGroupDiff';
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
-import { usePerformViewFieldAPIPersist } from '@/views/hooks/internal/usePerformViewFieldAPIPersist';
-import { usePerformViewFieldGroupAPIPersist } from '@/views/hooks/internal/usePerformViewFieldGroupAPIPersist';
import { useRefreshAllCoreViews } from '@/views/hooks/useRefreshAllCoreViews';
+import { useMutation } from '@apollo/client';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
-import {
- type FieldsConfiguration,
- WidgetConfigurationType,
-} from '~/generated-metadata/graphql';
+import { type ViewFragmentFragment } from '~/generated-metadata/graphql';
+
+type UpsertFieldsWidgetInput = {
+ widgetId: string;
+ groups?: {
+ id: string;
+ name: string;
+ position: number;
+ isVisible: boolean;
+ fields: {
+ viewFieldId: string;
+ isVisible: boolean;
+ position: number;
+ }[];
+ }[];
+ fields?: {
+ viewFieldId: string;
+ isVisible: boolean;
+ position: number;
+ }[];
+};
+
+type UpsertFieldsWidgetResult = {
+ upsertFieldsWidget: ViewFragmentFragment;
+};
type UseSaveFieldsWidgetGroupsParams = {
pageLayoutId: string;
@@ -31,52 +49,13 @@ export const useSaveFieldsWidgetGroups = ({
pageLayoutId,
);
- const pageLayoutPersistedState = useRecoilComponentCallbackState(
- pageLayoutPersistedComponentState,
- pageLayoutId,
- );
-
- const {
- performViewFieldGroupAPICreate,
- performViewFieldGroupAPIUpdate,
- performViewFieldGroupAPIDelete,
- } = usePerformViewFieldGroupAPIPersist();
-
- const { performViewFieldAPIUpdate } = usePerformViewFieldAPIPersist();
+ const [upsertFieldsWidgetMutation] = useMutation<
+ UpsertFieldsWidgetResult,
+ { input: UpsertFieldsWidgetInput }
+ >(UPSERT_FIELDS_WIDGET);
const { refreshAllCoreViews } = useRefreshAllCoreViews();
- const getViewIdForWidget = useRecoilCallback(
- ({ snapshot }) =>
- (widgetId: string): string | null => {
- const pageLayoutPersisted = snapshot
- .getLoadable(pageLayoutPersistedState)
- .getValue();
-
- if (!isDefined(pageLayoutPersisted)) {
- return null;
- }
-
- for (const tab of pageLayoutPersisted.tabs) {
- for (const widget of tab.widgets) {
- if (
- widget.id === widgetId &&
- isDefined(widget.configuration) &&
- widget.configuration.configurationType ===
- WidgetConfigurationType.FIELDS
- ) {
- return (
- (widget.configuration as FieldsConfiguration).viewId ?? null
- );
- }
- }
- }
-
- return null;
- },
- [pageLayoutPersistedState],
- );
-
const saveFieldsWidgetGroups = useRecoilCallback(
({ set, snapshot }) =>
async () => {
@@ -94,73 +73,33 @@ export const useSaveFieldsWidgetGroups = ({
for (const widgetId of widgetIds) {
const draftGroups = allDraftGroups[widgetId] ?? [];
- const persistedGroups = allPersistedGroups[widgetId] ?? [];
- if (draftGroups.length === 0 && persistedGroups.length === 0) {
- continue;
- }
-
- const viewId = getViewIdForWidget(widgetId);
-
- if (!isDefined(viewId)) {
- continue;
- }
-
- const { createdGroups, deletedGroups, updatedGroups } =
- computeFieldsWidgetGroupDiff(persistedGroups, draftGroups);
-
- if (createdGroups.length > 0) {
- await performViewFieldGroupAPICreate({
- inputs: createdGroups.map((group) => ({
- id: group.id,
- name: group.name,
- position: group.position,
- isVisible: group.isVisible,
- viewId,
- })),
- });
- }
-
- if (deletedGroups.length > 0) {
- for (const group of deletedGroups) {
- await performViewFieldGroupAPIDelete([
- { input: { id: group.id } },
- ]);
- }
- }
-
- if (updatedGroups.length > 0) {
- const updates = updatedGroups.map((group) => ({
+ await upsertFieldsWidgetMutation({
+ variables: {
input: {
- id: group.id,
- update: {
+ widgetId,
+ groups: draftGroups.map((group) => ({
+ id: group.id,
name: group.name,
position: group.position,
isVisible: group.isVisible,
- },
+ fields: group.fields.flatMap((field) => {
+ if (!isDefined(field.viewFieldId)) {
+ return [];
+ }
+
+ return [
+ {
+ viewFieldId: field.viewFieldId,
+ isVisible: field.isVisible,
+ position: field.position,
+ },
+ ];
+ }),
+ })),
},
- }));
-
- await performViewFieldGroupAPIUpdate(updates);
- }
-
- const fieldUpdates = computeFieldsWidgetFieldDiff(
- persistedGroups,
- draftGroups,
- );
-
- if (fieldUpdates.length > 0) {
- const viewFieldUpdateInputs = fieldUpdates.map(
- ({ viewFieldId, ...updates }) => ({
- input: {
- id: viewFieldId,
- update: updates,
- },
- }),
- );
-
- await performViewFieldAPIUpdate(viewFieldUpdateInputs);
- }
+ },
+ });
}
set(fieldsWidgetGroupsPersistedState, allDraftGroups);
@@ -172,11 +111,7 @@ export const useSaveFieldsWidgetGroups = ({
[
fieldsWidgetGroupsDraftState,
fieldsWidgetGroupsPersistedState,
- getViewIdForWidget,
- performViewFieldGroupAPICreate,
- performViewFieldGroupAPIDelete,
- performViewFieldGroupAPIUpdate,
- performViewFieldAPIUpdate,
+ upsertFieldsWidgetMutation,
refreshAllCoreViews,
],
);
diff --git a/packages/twenty-server/src/engine/metadata-modules/view-field-group/dtos/inputs/upsert-fields-widget-field.input.ts b/packages/twenty-server/src/engine/metadata-modules/view-field-group/dtos/inputs/upsert-fields-widget-field.input.ts
new file mode 100644
index 0000000000..4c258f68c0
--- /dev/null
+++ b/packages/twenty-server/src/engine/metadata-modules/view-field-group/dtos/inputs/upsert-fields-widget-field.input.ts
@@ -0,0 +1,21 @@
+import { Field, InputType } from '@nestjs/graphql';
+
+import { IsBoolean, IsNotEmpty, IsNumber, IsUUID } from 'class-validator';
+
+import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
+
+@InputType()
+export class UpsertFieldsWidgetFieldInput {
+ @IsUUID()
+ @IsNotEmpty()
+ @Field(() => UUIDScalarType, { description: 'The id of the view field' })
+ viewFieldId: string;
+
+ @IsBoolean()
+ @Field()
+ isVisible: boolean;
+
+ @IsNumber()
+ @Field()
+ position: number;
+}
diff --git a/packages/twenty-server/src/engine/metadata-modules/view-field-group/dtos/inputs/upsert-fields-widget-group.input.ts b/packages/twenty-server/src/engine/metadata-modules/view-field-group/dtos/inputs/upsert-fields-widget-group.input.ts
new file mode 100644
index 0000000000..01651f0881
--- /dev/null
+++ b/packages/twenty-server/src/engine/metadata-modules/view-field-group/dtos/inputs/upsert-fields-widget-group.input.ts
@@ -0,0 +1,40 @@
+import { Field, InputType } from '@nestjs/graphql';
+
+import { Type } from 'class-transformer';
+import {
+ IsBoolean,
+ IsNotEmpty,
+ IsNumber,
+ IsString,
+ IsUUID,
+ ValidateNested,
+} from 'class-validator';
+
+import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
+import { UpsertFieldsWidgetFieldInput } from 'src/engine/metadata-modules/view-field-group/dtos/inputs/upsert-fields-widget-field.input';
+
+@InputType()
+export class UpsertFieldsWidgetGroupInput {
+ @IsUUID()
+ @IsNotEmpty()
+ @Field(() => UUIDScalarType)
+ id: string;
+
+ @IsString()
+ @IsNotEmpty()
+ @Field()
+ name: string;
+
+ @IsNumber()
+ @Field()
+ position: number;
+
+ @IsBoolean()
+ @Field()
+ isVisible: boolean;
+
+ @ValidateNested({ each: true })
+ @Type(() => UpsertFieldsWidgetFieldInput)
+ @Field(() => [UpsertFieldsWidgetFieldInput])
+ fields: UpsertFieldsWidgetFieldInput[];
+}
diff --git a/packages/twenty-server/src/engine/metadata-modules/view-field-group/dtos/inputs/upsert-fields-widget.input.ts b/packages/twenty-server/src/engine/metadata-modules/view-field-group/dtos/inputs/upsert-fields-widget.input.ts
new file mode 100644
index 0000000000..e022f9e835
--- /dev/null
+++ b/packages/twenty-server/src/engine/metadata-modules/view-field-group/dtos/inputs/upsert-fields-widget.input.ts
@@ -0,0 +1,44 @@
+import { Field, InputType } from '@nestjs/graphql';
+
+import { Type } from 'class-transformer';
+import {
+ IsNotEmpty,
+ IsOptional,
+ IsUUID,
+ ValidateNested,
+} from 'class-validator';
+
+import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
+import { UpsertFieldsWidgetFieldInput } from 'src/engine/metadata-modules/view-field-group/dtos/inputs/upsert-fields-widget-field.input';
+import { UpsertFieldsWidgetGroupInput } from 'src/engine/metadata-modules/view-field-group/dtos/inputs/upsert-fields-widget-group.input';
+
+@InputType()
+export class UpsertFieldsWidgetInput {
+ @IsUUID()
+ @IsNotEmpty()
+ @Field(() => UUIDScalarType, {
+ description:
+ 'The id of the fields widget whose groups and fields to upsert',
+ })
+ widgetId: string;
+
+ @IsOptional()
+ @ValidateNested({ each: true })
+ @Type(() => UpsertFieldsWidgetGroupInput)
+ @Field(() => [UpsertFieldsWidgetGroupInput], {
+ nullable: true,
+ description:
+ 'The groups (with nested fields) to upsert. Mutually exclusive with "fields".',
+ })
+ groups?: UpsertFieldsWidgetGroupInput[];
+
+ @IsOptional()
+ @ValidateNested({ each: true })
+ @Type(() => UpsertFieldsWidgetFieldInput)
+ @Field(() => [UpsertFieldsWidgetFieldInput], {
+ nullable: true,
+ description:
+ 'The ungrouped fields to upsert. When provided, all existing groups are deleted and fields are detached from groups. Mutually exclusive with "groups".',
+ })
+ fields?: UpsertFieldsWidgetFieldInput[];
+}
diff --git a/packages/twenty-server/src/engine/metadata-modules/view-field-group/exceptions/view-field-group.exception.ts b/packages/twenty-server/src/engine/metadata-modules/view-field-group/exceptions/view-field-group.exception.ts
index d49eb40120..26b62288d1 100644
--- a/packages/twenty-server/src/engine/metadata-modules/view-field-group/exceptions/view-field-group.exception.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/view-field-group/exceptions/view-field-group.exception.ts
@@ -19,5 +19,6 @@ export class ViewFieldGroupException extends CustomException ViewFieldGroupDTO)
@UseFilters(ViewGraphqlApiExceptionFilter)
@UseGuards(WorkspaceAuthGuard)
export class ViewFieldGroupResolver {
- constructor(private readonly viewFieldGroupService: ViewFieldGroupService) {}
+ constructor(
+ private readonly viewFieldGroupService: ViewFieldGroupService,
+ private readonly fieldsWidgetUpsertService: FieldsWidgetUpsertService,
+ ) {}
@Query(() => [ViewFieldGroupDTO])
@UseGuards(NoPermissionGuard)
@@ -113,6 +120,18 @@ export class ViewFieldGroupResolver {
});
}
+ @Mutation(() => ViewDTO)
+ @UseGuards(NoPermissionGuard)
+ async upsertFieldsWidget(
+ @Args('input') input: UpsertFieldsWidgetInput,
+ @AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
+ ): Promise {
+ return await this.fieldsWidgetUpsertService.upsertFieldsWidget({
+ input,
+ workspaceId,
+ });
+ }
+
@ResolveField(() => [ViewFieldDTO])
async viewFields(
@Parent() viewFieldGroup: ViewFieldGroupDTO,
diff --git a/packages/twenty-server/src/engine/metadata-modules/view-field-group/services/fields-widget-upsert.service.ts b/packages/twenty-server/src/engine/metadata-modules/view-field-group/services/fields-widget-upsert.service.ts
new file mode 100644
index 0000000000..51928fe646
--- /dev/null
+++ b/packages/twenty-server/src/engine/metadata-modules/view-field-group/services/fields-widget-upsert.service.ts
@@ -0,0 +1,444 @@
+import { Injectable } from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+
+import { t } from '@lingui/core/macro';
+import { isDefined } from 'twenty-shared/utils';
+import { IsNull, Repository } from 'typeorm';
+
+import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
+import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
+import { addFlatEntityToFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/add-flat-entity-to-flat-entity-maps-or-throw.util';
+import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
+import { resolveEntityRelationUniversalIdentifiers } from 'src/engine/metadata-modules/flat-entity/utils/resolve-entity-relation-universal-identifiers.util';
+import { isFlatPageLayoutWidgetConfigurationOfType } from 'src/engine/metadata-modules/flat-page-layout-widget/utils/is-flat-page-layout-widget-configuration-of-type.util';
+import { type FlatViewFieldGroupMaps } from 'src/engine/metadata-modules/flat-view-field-group/types/flat-view-field-group-maps.type';
+import { type FlatViewFieldGroup } from 'src/engine/metadata-modules/flat-view-field-group/types/flat-view-field-group.type';
+import { type FlatViewField } from 'src/engine/metadata-modules/flat-view-field/types/flat-view-field.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 { type UpsertFieldsWidgetFieldInput } from 'src/engine/metadata-modules/view-field-group/dtos/inputs/upsert-fields-widget-field.input';
+import { UpsertFieldsWidgetGroupInput } from 'src/engine/metadata-modules/view-field-group/dtos/inputs/upsert-fields-widget-group.input';
+import { UpsertFieldsWidgetInput } from 'src/engine/metadata-modules/view-field-group/dtos/inputs/upsert-fields-widget.input';
+import {
+ ViewFieldGroupException,
+ ViewFieldGroupExceptionCode,
+} from 'src/engine/metadata-modules/view-field-group/exceptions/view-field-group.exception';
+import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
+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';
+
+@Injectable()
+export class FieldsWidgetUpsertService {
+ constructor(
+ private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
+ private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
+ private readonly applicationService: ApplicationService,
+ @InjectRepository(ViewEntity)
+ private readonly viewRepository: Repository,
+ ) {}
+
+ async upsertFieldsWidget({
+ input,
+ workspaceId,
+ }: {
+ input: UpsertFieldsWidgetInput;
+ workspaceId: string;
+ }): Promise {
+ const hasGroups = isDefined(input.groups);
+ const hasFields = isDefined(input.fields);
+
+ if (hasGroups === hasFields) {
+ throw new ViewFieldGroupException(
+ t`Exactly one of "groups" or "fields" must be provided`,
+ ViewFieldGroupExceptionCode.INVALID_VIEW_FIELD_GROUP_DATA,
+ );
+ }
+
+ const { widgetId } = input;
+
+ const { workspaceCustomFlatApplication } =
+ await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
+ { workspaceId },
+ );
+
+ const {
+ flatPageLayoutWidgetMaps,
+ flatViewFieldGroupMaps,
+ flatViewFieldMaps,
+ flatViewMaps,
+ } =
+ await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
+ {
+ workspaceId,
+ flatMapsKeys: [
+ 'flatPageLayoutWidgetMaps',
+ 'flatViewFieldGroupMaps',
+ 'flatViewFieldMaps',
+ 'flatViewMaps',
+ ],
+ },
+ );
+
+ const widget = findFlatEntityByIdInFlatEntityMaps({
+ flatEntityId: widgetId,
+ flatEntityMaps: flatPageLayoutWidgetMaps,
+ });
+
+ if (
+ !isDefined(widget) ||
+ !isFlatPageLayoutWidgetConfigurationOfType(
+ widget,
+ WidgetConfigurationType.FIELDS,
+ )
+ ) {
+ throw new ViewFieldGroupException(
+ t`Fields widget not found`,
+ ViewFieldGroupExceptionCode.FIELDS_WIDGET_NOT_FOUND,
+ );
+ }
+
+ const viewId = widget.configuration.viewId;
+
+ if (!isDefined(viewId)) {
+ throw new ViewFieldGroupException(
+ t`Fields widget has no associated view`,
+ ViewFieldGroupExceptionCode.VIEW_NOT_FOUND,
+ );
+ }
+
+ const existingGroups = Object.values(
+ flatViewFieldGroupMaps.byUniversalIdentifier,
+ )
+ .filter(isDefined)
+ .filter(
+ (group) => !isDefined(group.deletedAt) && group.viewId === viewId,
+ );
+
+ const existingViewFields = Object.values(
+ flatViewFieldMaps.byUniversalIdentifier,
+ )
+ .filter(isDefined)
+ .filter(
+ (field) => !isDefined(field.deletedAt) && field.viewId === viewId,
+ );
+
+ if (hasGroups) {
+ await this.upsertFieldsWidgetWithGroups({
+ inputGroups: input.groups!,
+ existingGroups,
+ existingViewFields,
+ viewId,
+ workspaceId,
+ applicationId: workspaceCustomFlatApplication.id,
+ applicationUniversalIdentifier:
+ workspaceCustomFlatApplication.universalIdentifier,
+ flatViewMaps,
+ flatViewFieldGroupMaps,
+ });
+ } else {
+ await this.upsertFieldsWidgetWithFields({
+ inputFields: input.fields!,
+ existingGroups,
+ existingViewFields,
+ workspaceId,
+ applicationUniversalIdentifier:
+ workspaceCustomFlatApplication.universalIdentifier,
+ });
+ }
+
+ const view = await this.viewRepository.findOne({
+ where: { id: viewId, workspaceId, deletedAt: IsNull() },
+ });
+
+ if (!isDefined(view)) {
+ throw new ViewFieldGroupException(
+ t`View not found after upsert`,
+ ViewFieldGroupExceptionCode.VIEW_NOT_FOUND,
+ );
+ }
+
+ return view;
+ }
+
+ private async upsertFieldsWidgetWithGroups({
+ inputGroups,
+ existingGroups,
+ existingViewFields,
+ viewId,
+ workspaceId,
+ applicationId,
+ applicationUniversalIdentifier,
+ flatViewMaps,
+ flatViewFieldGroupMaps,
+ }: {
+ inputGroups: UpsertFieldsWidgetGroupInput[];
+ existingGroups: FlatViewFieldGroup[];
+ existingViewFields: FlatViewField[];
+ viewId: string;
+ workspaceId: string;
+ applicationId: string;
+ applicationUniversalIdentifier: string;
+ flatViewMaps: FlatViewMaps;
+ flatViewFieldGroupMaps: FlatViewFieldGroupMaps;
+ }): Promise {
+ const now = new Date().toISOString();
+ const inputGroupIds = new Set(inputGroups.map((g) => g.id));
+
+ const groupsToCreate: FlatViewFieldGroup[] = [];
+ const groupsToUpdate: FlatViewFieldGroup[] = [];
+ const groupsToDelete: FlatViewFieldGroup[] = [];
+
+ for (const inputGroup of inputGroups) {
+ const existingGroup = existingGroups.find((g) => g.id === inputGroup.id);
+
+ if (!isDefined(existingGroup)) {
+ groupsToCreate.push(
+ this.buildGroupToCreate({
+ inputGroup,
+ viewId,
+ workspaceId,
+ applicationId,
+ applicationUniversalIdentifier,
+ now,
+ flatViewMaps,
+ }),
+ );
+ } else if (this.hasGroupChanged(existingGroup, inputGroup)) {
+ groupsToUpdate.push({
+ ...existingGroup,
+ name: inputGroup.name,
+ position: inputGroup.position,
+ isVisible: inputGroup.isVisible,
+ updatedAt: now,
+ });
+ }
+ }
+
+ for (const existingGroup of existingGroups) {
+ if (!inputGroupIds.has(existingGroup.id)) {
+ groupsToDelete.push(existingGroup);
+ }
+ }
+
+ // Build optimistic maps so that newly created groups can be resolved when
+ // computing viewFieldGroupUniversalIdentifier for view field updates.
+ const optimisticFlatViewFieldGroupMaps: FlatViewFieldGroupMaps =
+ groupsToCreate.reduce(
+ (maps, group) =>
+ addFlatEntityToFlatEntityMapsOrThrow({
+ flatEntity: group,
+ flatEntityMaps: maps,
+ }),
+ flatViewFieldGroupMaps,
+ );
+
+ const viewFieldsToUpdate = existingViewFields.flatMap((existingField) => {
+ const inputGroup = inputGroups.find((g) =>
+ g.fields.some((f) => f.viewFieldId === existingField.id),
+ );
+
+ if (!isDefined(inputGroup)) {
+ return [];
+ }
+
+ const inputField = inputGroup.fields.find(
+ (f) => f.viewFieldId === existingField.id,
+ );
+
+ if (!isDefined(inputField)) {
+ return [];
+ }
+
+ const newViewFieldGroupId = inputGroup.id;
+
+ const hasChanged =
+ existingField.isVisible !== inputField.isVisible ||
+ existingField.position !== inputField.position ||
+ existingField.viewFieldGroupId !== newViewFieldGroupId;
+
+ if (!hasChanged) {
+ return [];
+ }
+
+ const { viewFieldGroupUniversalIdentifier } =
+ resolveEntityRelationUniversalIdentifiers({
+ metadataName: 'viewField',
+ foreignKeyValues: {
+ viewFieldGroupId: newViewFieldGroupId,
+ },
+ flatEntityMaps: {
+ flatViewFieldGroupMaps: optimisticFlatViewFieldGroupMaps,
+ },
+ });
+
+ return [
+ {
+ ...existingField,
+ isVisible: inputField.isVisible,
+ position: inputField.position,
+ viewFieldGroupId: newViewFieldGroupId,
+ viewFieldGroupUniversalIdentifier,
+ updatedAt: now,
+ },
+ ];
+ });
+
+ const validateAndBuildResult =
+ await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
+ {
+ allFlatEntityOperationByMetadataName: {
+ viewFieldGroup: {
+ flatEntityToCreate: groupsToCreate,
+ flatEntityToDelete: groupsToDelete,
+ flatEntityToUpdate: groupsToUpdate,
+ },
+ viewField: {
+ flatEntityToCreate: [],
+ flatEntityToDelete: [],
+ flatEntityToUpdate: viewFieldsToUpdate,
+ },
+ },
+ workspaceId,
+ isSystemBuild: false,
+ applicationUniversalIdentifier,
+ },
+ );
+
+ if (validateAndBuildResult.status === 'fail') {
+ throw new WorkspaceMigrationBuilderException(
+ validateAndBuildResult,
+ 'Multiple validation errors occurred while upserting fields widget',
+ );
+ }
+ }
+
+ private async upsertFieldsWidgetWithFields({
+ inputFields,
+ existingGroups,
+ existingViewFields,
+ workspaceId,
+ applicationUniversalIdentifier,
+ }: {
+ inputFields: UpsertFieldsWidgetFieldInput[];
+ existingGroups: FlatViewFieldGroup[];
+ existingViewFields: FlatViewField[];
+ workspaceId: string;
+ applicationUniversalIdentifier: string;
+ }): Promise {
+ const now = new Date().toISOString();
+
+ const groupsToDelete: FlatViewFieldGroup[] = [...existingGroups];
+
+ const viewFieldsToUpdate = existingViewFields.flatMap((existingField) => {
+ const inputField = inputFields.find(
+ (f) => f.viewFieldId === existingField.id,
+ );
+
+ if (!isDefined(inputField)) {
+ return [];
+ }
+
+ const hasChanged =
+ existingField.isVisible !== inputField.isVisible ||
+ existingField.position !== inputField.position ||
+ existingField.viewFieldGroupId !== null;
+
+ if (!hasChanged) {
+ return [];
+ }
+
+ return [
+ {
+ ...existingField,
+ isVisible: inputField.isVisible,
+ position: inputField.position,
+ viewFieldGroupId: null,
+ viewFieldGroupUniversalIdentifier: null,
+ updatedAt: now,
+ },
+ ];
+ });
+
+ const validateAndBuildResult =
+ await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
+ {
+ allFlatEntityOperationByMetadataName: {
+ viewFieldGroup: {
+ flatEntityToCreate: [],
+ flatEntityToDelete: groupsToDelete,
+ flatEntityToUpdate: [],
+ },
+ viewField: {
+ flatEntityToCreate: [],
+ flatEntityToDelete: [],
+ flatEntityToUpdate: viewFieldsToUpdate,
+ },
+ },
+ workspaceId,
+ isSystemBuild: false,
+ applicationUniversalIdentifier,
+ },
+ );
+
+ if (validateAndBuildResult.status === 'fail') {
+ throw new WorkspaceMigrationBuilderException(
+ validateAndBuildResult,
+ 'Multiple validation errors occurred while upserting fields widget',
+ );
+ }
+ }
+
+ private buildGroupToCreate({
+ inputGroup,
+ viewId,
+ workspaceId,
+ applicationId,
+ applicationUniversalIdentifier,
+ now,
+ flatViewMaps,
+ }: {
+ inputGroup: UpsertFieldsWidgetGroupInput;
+ viewId: string;
+ workspaceId: string;
+ applicationId: string;
+ applicationUniversalIdentifier: string;
+ now: string;
+ flatViewMaps: FlatViewMaps;
+ }): FlatViewFieldGroup {
+ const { viewUniversalIdentifier } =
+ resolveEntityRelationUniversalIdentifiers({
+ metadataName: 'viewFieldGroup',
+ foreignKeyValues: { viewId },
+ flatEntityMaps: { flatViewMaps },
+ });
+
+ return {
+ id: inputGroup.id,
+ workspaceId,
+ applicationId,
+ universalIdentifier: inputGroup.id,
+ applicationUniversalIdentifier,
+ name: inputGroup.name,
+ position: inputGroup.position,
+ isVisible: inputGroup.isVisible,
+ viewId,
+ viewUniversalIdentifier,
+ createdAt: now,
+ updatedAt: now,
+ deletedAt: null,
+ viewFieldIds: [],
+ viewFieldUniversalIdentifiers: [],
+ };
+ }
+
+ private hasGroupChanged(
+ existing: FlatViewFieldGroup,
+ input: UpsertFieldsWidgetGroupInput,
+ ): boolean {
+ return (
+ existing.name !== input.name ||
+ existing.position !== input.position ||
+ existing.isVisible !== input.isVisible
+ );
+ }
+}
diff --git a/packages/twenty-server/src/engine/metadata-modules/view-field-group/view-field-group.module.ts b/packages/twenty-server/src/engine/metadata-modules/view-field-group/view-field-group.module.ts
index efe6c3b46d..f3eb6ac7ed 100644
--- a/packages/twenty-server/src/engine/metadata-modules/view-field-group/view-field-group.module.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/view-field-group/view-field-group.module.ts
@@ -6,6 +6,7 @@ import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { ViewFieldGroupEntity } from 'src/engine/metadata-modules/view-field-group/entities/view-field-group.entity';
import { ViewFieldGroupResolver } from 'src/engine/metadata-modules/view-field-group/resolvers/view-field-group.resolver';
+import { FieldsWidgetUpsertService } from 'src/engine/metadata-modules/view-field-group/services/fields-widget-upsert.service';
import { ViewFieldGroupService } from 'src/engine/metadata-modules/view-field-group/services/view-field-group.service';
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
@@ -20,7 +21,11 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
WorkspaceMigrationModule,
WorkspaceManyOrAllFlatEntityMapsCacheModule,
],
- providers: [ViewFieldGroupResolver, ViewFieldGroupService],
+ providers: [
+ ViewFieldGroupResolver,
+ ViewFieldGroupService,
+ FieldsWidgetUpsertService,
+ ],
exports: [ViewFieldGroupService],
})
export class ViewFieldGroupModule {}
diff --git a/packages/twenty-server/src/engine/metadata-modules/view-field/dtos/view-field.dto.ts b/packages/twenty-server/src/engine/metadata-modules/view-field/dtos/view-field.dto.ts
index ea0097d7ef..cac03c6011 100644
--- a/packages/twenty-server/src/engine/metadata-modules/view-field/dtos/view-field.dto.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/view-field/dtos/view-field.dto.ts
@@ -30,6 +30,9 @@ export class ViewFieldDTO {
@Field(() => UUIDScalarType, { nullable: false })
viewId: string;
+ @Field(() => UUIDScalarType, { nullable: true })
+ viewFieldGroupId?: string | null;
+
@Field(() => UUIDScalarType, { nullable: false })
workspaceId: string;
diff --git a/packages/twenty-server/src/engine/metadata-modules/view/utils/view-graphql-api-exception-handler.util.ts b/packages/twenty-server/src/engine/metadata-modules/view/utils/view-graphql-api-exception-handler.util.ts
index f5c740fd48..107c77ef4d 100644
--- a/packages/twenty-server/src/engine/metadata-modules/view/utils/view-graphql-api-exception-handler.util.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/view/utils/view-graphql-api-exception-handler.util.ts
@@ -87,6 +87,8 @@ export const viewGraphqlApiExceptionHandler = (error: Error, i18n: I18n) => {
throw new NotFoundError(error.message);
case ViewFieldGroupExceptionCode.VIEW_NOT_FOUND:
throw new NotFoundError(error.message);
+ case ViewFieldGroupExceptionCode.FIELDS_WIDGET_NOT_FOUND:
+ throw new NotFoundError(error.message);
case ViewFieldGroupExceptionCode.INVALID_VIEW_FIELD_GROUP_DATA:
throw new UserInputError(error.message, {
userFriendlyMessage: error.userFriendlyMessage,
diff --git a/packages/twenty-server/test/integration/constants/view-gql-fields.constants.ts b/packages/twenty-server/test/integration/constants/view-gql-fields.constants.ts
index 01778ace56..6c0c292688 100644
--- a/packages/twenty-server/test/integration/constants/view-gql-fields.constants.ts
+++ b/packages/twenty-server/test/integration/constants/view-gql-fields.constants.ts
@@ -23,6 +23,7 @@ export const VIEW_FIELD_GQL_FIELDS = `
isVisible
size
viewId
+ viewFieldGroupId
createdAt
updatedAt
deletedAt
diff --git a/packages/twenty-server/test/integration/metadata/suites/view-field-group/upsert-fields-widget.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/view-field-group/upsert-fields-widget.integration-spec.ts
new file mode 100644
index 0000000000..27dbc7a941
--- /dev/null
+++ b/packages/twenty-server/test/integration/metadata/suites/view-field-group/upsert-fields-widget.integration-spec.ts
@@ -0,0 +1,504 @@
+import {
+ VIEW_FIELD_GQL_FIELDS,
+ VIEW_FIELD_GROUP_GQL_FIELDS,
+ VIEW_GQL_FIELDS,
+} from 'test/integration/constants/view-gql-fields.constants';
+import { findCoreViewFieldGroups } from 'test/integration/metadata/suites/view-field-group/utils/find-core-view-field-groups.util';
+import { upsertFieldsWidget } from 'test/integration/metadata/suites/view-field-group/utils/upsert-fields-widget.util';
+import { findCoreViewFields } from 'test/integration/metadata/suites/view-field/utils/find-core-view-fields.util';
+import { v4 as uuidv4 } from 'uuid';
+
+type FieldsWidgetTestSetup = {
+ widgetId: string;
+ viewId: string;
+ viewFields: Array<{
+ id: string;
+ fieldMetadataId: string;
+ position: number;
+ isVisible: boolean;
+ viewFieldGroupId: string | null;
+ }>;
+};
+
+const VIEW_WITH_FIELDS_AND_GROUPS_GQL_FIELDS = `
+ ${VIEW_GQL_FIELDS}
+ viewFields {
+ ${VIEW_FIELD_GQL_FIELDS}
+ }
+ viewFieldGroups {
+ ${VIEW_FIELD_GROUP_GQL_FIELDS}
+ }
+`;
+
+const fetchFieldsWidgetTestSetup = async (): Promise => {
+ const widgets = await global.testDataSource.query(
+ `SELECT id, configuration->>'viewId' AS "viewId"
+ FROM core."pageLayoutWidget"
+ WHERE type = 'FIELDS'
+ AND "deletedAt" IS NULL
+ LIMIT 1`,
+ );
+
+ expect(widgets.length).toBeGreaterThan(0);
+
+ const { id: widgetId, viewId } = widgets[0];
+
+ expect(widgetId).toBeDefined();
+ expect(viewId).toBeDefined();
+
+ const { data } = await findCoreViewFields({
+ viewId,
+ gqlFields: 'id fieldMetadataId position isVisible viewFieldGroupId',
+ expectToFail: false,
+ });
+
+ const viewFields = data.getCoreViewFields;
+
+ return { widgetId, viewId, viewFields };
+};
+
+describe('upsertFieldsWidget', () => {
+ let testSetup: FieldsWidgetTestSetup;
+
+ beforeAll(async () => {
+ testSetup = await fetchFieldsWidgetTestSetup();
+ });
+
+ describe('with groups input', () => {
+ it('should upsert fields widget with a new group and return a view', async () => {
+ const newGroupId = uuidv4();
+ const targetFields = testSetup.viewFields.slice(0, 2);
+
+ const { data, errors } = await upsertFieldsWidget({
+ expectToFail: false,
+ input: {
+ widgetId: testSetup.widgetId,
+ groups: [
+ {
+ id: newGroupId,
+ name: 'Test Group',
+ position: 0,
+ isVisible: true,
+ fields: targetFields.map((f, index) => ({
+ viewFieldId: f.id,
+ isVisible: true,
+ position: index,
+ })),
+ },
+ ],
+ },
+ gqlFields: VIEW_WITH_FIELDS_AND_GROUPS_GQL_FIELDS,
+ });
+
+ expect(errors).toBeUndefined();
+ expect(data.upsertFieldsWidget).toBeDefined();
+ expect(data.upsertFieldsWidget.id).toBeDefined();
+ expect(data.upsertFieldsWidget.name).toBeDefined();
+
+ const { data: groupsData } = await findCoreViewFieldGroups({
+ viewId: testSetup.viewId,
+ gqlFields: 'id name',
+ expectToFail: false,
+ });
+
+ const createdGroup = groupsData.getCoreViewFieldGroups.find(
+ (g: { id: string }) => g.id === newGroupId,
+ );
+
+ expect(createdGroup).toBeDefined();
+ expect(createdGroup!.name).toBe('Test Group');
+ });
+
+ it('should hard-delete groups not included in the input', async () => {
+ // First, create a group via upsert
+ const groupToDeleteId = uuidv4();
+ const groupToKeepId = uuidv4();
+
+ const twoFields = testSetup.viewFields.slice(0, 2);
+
+ await upsertFieldsWidget({
+ expectToFail: false,
+ input: {
+ widgetId: testSetup.widgetId,
+ groups: [
+ {
+ id: groupToDeleteId,
+ name: 'Group To Delete',
+ position: 0,
+ isVisible: true,
+ fields: [
+ {
+ viewFieldId: twoFields[0].id,
+ isVisible: true,
+ position: 0,
+ },
+ ],
+ },
+ {
+ id: groupToKeepId,
+ name: 'Group To Keep',
+ position: 1,
+ isVisible: true,
+ fields: [
+ {
+ viewFieldId: twoFields[1].id,
+ isVisible: true,
+ position: 0,
+ },
+ ],
+ },
+ ],
+ },
+ });
+
+ // Now upsert again without the first group
+ await upsertFieldsWidget({
+ expectToFail: false,
+ input: {
+ widgetId: testSetup.widgetId,
+ groups: [
+ {
+ id: groupToKeepId,
+ name: 'Group To Keep',
+ position: 0,
+ isVisible: true,
+ fields: [
+ {
+ viewFieldId: twoFields[1].id,
+ isVisible: true,
+ position: 0,
+ },
+ ],
+ },
+ ],
+ },
+ });
+
+ // Verify the omitted group was hard-deleted (row should be completely gone)
+ const deletedGroup = await global.testDataSource.query(
+ `SELECT id FROM core."viewFieldGroup"
+ WHERE id = $1`,
+ [groupToDeleteId],
+ );
+
+ expect(deletedGroup.length).toBe(0);
+
+ // Verify the kept group is still active
+ const { data: keptGroupData } = await findCoreViewFieldGroups({
+ viewId: testSetup.viewId,
+ gqlFields: 'id',
+ expectToFail: false,
+ });
+
+ const keptGroup = keptGroupData.getCoreViewFieldGroups.find(
+ (g: { id: string }) => g.id === groupToKeepId,
+ );
+
+ expect(keptGroup).toBeDefined();
+ });
+
+ it('should update view field positions and visibility within groups', async () => {
+ const groupId = uuidv4();
+ const targetField = testSetup.viewFields[0];
+
+ await upsertFieldsWidget({
+ expectToFail: false,
+ input: {
+ widgetId: testSetup.widgetId,
+ groups: [
+ {
+ id: groupId,
+ name: 'Position Test Group',
+ position: 0,
+ isVisible: true,
+ fields: [
+ {
+ viewFieldId: targetField.id,
+ isVisible: false,
+ position: 42,
+ },
+ ],
+ },
+ ],
+ },
+ });
+
+ // Verify the view field was updated
+ const { data: fieldsData } = await findCoreViewFields({
+ viewId: testSetup.viewId,
+ gqlFields: 'id isVisible position viewFieldGroupId',
+ expectToFail: false,
+ });
+
+ const updatedField = fieldsData.getCoreViewFields.find(
+ (f: { id: string }) => f.id === targetField.id,
+ );
+
+ expect(updatedField).toBeDefined();
+ expect(updatedField!.isVisible).toBe(false);
+ expect(updatedField!.position).toBe(42);
+ expect(updatedField!.viewFieldGroupId).toBe(groupId);
+ });
+ });
+
+ describe('with fields input (ungrouped)', () => {
+ it('should upsert fields widget with flat fields and return a view', async () => {
+ const targetFields = testSetup.viewFields.slice(0, 3);
+
+ const { data, errors } = await upsertFieldsWidget({
+ expectToFail: false,
+ input: {
+ widgetId: testSetup.widgetId,
+ fields: targetFields.map((f, index) => ({
+ viewFieldId: f.id,
+ isVisible: true,
+ position: index,
+ })),
+ },
+ gqlFields: VIEW_WITH_FIELDS_AND_GROUPS_GQL_FIELDS,
+ });
+
+ expect(errors).toBeUndefined();
+ expect(data.upsertFieldsWidget).toBeDefined();
+ expect(data.upsertFieldsWidget.id).toBeDefined();
+ });
+
+ it('should hard-delete all existing groups when using flat fields', async () => {
+ // First create a group via upsert
+ const groupId = uuidv4();
+ const targetField = testSetup.viewFields[0];
+
+ await upsertFieldsWidget({
+ expectToFail: false,
+ input: {
+ widgetId: testSetup.widgetId,
+ groups: [
+ {
+ id: groupId,
+ name: 'Group To Be Deleted',
+ position: 0,
+ isVisible: true,
+ fields: [
+ {
+ viewFieldId: targetField.id,
+ isVisible: true,
+ position: 0,
+ },
+ ],
+ },
+ ],
+ },
+ });
+
+ // Verify group exists
+ const { data: groupBeforeData } = await findCoreViewFieldGroups({
+ viewId: testSetup.viewId,
+ gqlFields: 'id',
+ expectToFail: false,
+ });
+
+ const groupBefore = groupBeforeData.getCoreViewFieldGroups.find(
+ (g: { id: string }) => g.id === groupId,
+ );
+
+ expect(groupBefore).toBeDefined();
+
+ // Now upsert with flat fields
+ await upsertFieldsWidget({
+ expectToFail: false,
+ input: {
+ widgetId: testSetup.widgetId,
+ fields: [
+ {
+ viewFieldId: targetField.id,
+ isVisible: true,
+ position: 0,
+ },
+ ],
+ },
+ });
+
+ // Verify all groups are soft-deleted
+ const { data: activeGroupsData } = await findCoreViewFieldGroups({
+ viewId: testSetup.viewId,
+ gqlFields: 'id',
+ expectToFail: false,
+ });
+
+ expect(activeGroupsData.getCoreViewFieldGroups.length).toBe(0);
+
+ // Verify the field's viewFieldGroupId is null
+ const { data: updatedFieldData } = await findCoreViewFields({
+ viewId: testSetup.viewId,
+ gqlFields: 'id viewFieldGroupId',
+ expectToFail: false,
+ });
+
+ const updatedField = updatedFieldData.getCoreViewFields.find(
+ (f: { id: string }) => f.id === targetField.id,
+ );
+
+ expect(updatedField).toBeDefined();
+ expect(updatedField!.viewFieldGroupId).toBeNull();
+ });
+
+ it('should update field positions and visibility without groups', async () => {
+ const targetField = testSetup.viewFields[0];
+ const groupId = uuidv4();
+
+ // First assign the field to a group so it has a non-null viewFieldGroupId
+ await upsertFieldsWidget({
+ expectToFail: false,
+ input: {
+ widgetId: testSetup.widgetId,
+ groups: [
+ {
+ id: groupId,
+ name: 'Temporary Group',
+ position: 0,
+ isVisible: true,
+ fields: [
+ {
+ viewFieldId: targetField.id,
+ isVisible: true,
+ position: 0,
+ },
+ ],
+ },
+ ],
+ },
+ });
+
+ // Now switch to flat fields with different position and visibility
+ await upsertFieldsWidget({
+ expectToFail: false,
+ input: {
+ widgetId: testSetup.widgetId,
+ fields: [
+ {
+ viewFieldId: targetField.id,
+ isVisible: false,
+ position: 99,
+ },
+ ],
+ },
+ });
+
+ const { data: updatedFieldData } = await findCoreViewFields({
+ viewId: testSetup.viewId,
+ gqlFields: 'id isVisible position viewFieldGroupId',
+ expectToFail: false,
+ });
+
+ const updatedField = updatedFieldData.getCoreViewFields.find(
+ (f: { id: string }) => f.id === targetField.id,
+ );
+
+ expect(updatedField).toBeDefined();
+ expect(updatedField!.isVisible).toBe(false);
+ expect(updatedField!.position).toBe(99);
+ expect(updatedField!.viewFieldGroupId).toBeNull();
+ });
+ });
+
+ describe('validation', () => {
+ it('should fail when both groups and fields are provided', async () => {
+ const targetField = testSetup.viewFields[0];
+
+ const { errors } = await upsertFieldsWidget({
+ expectToFail: true,
+ input: {
+ widgetId: testSetup.widgetId,
+ groups: [
+ {
+ id: uuidv4(),
+ name: 'Test',
+ position: 0,
+ isVisible: true,
+ fields: [
+ {
+ viewFieldId: targetField.id,
+ isVisible: true,
+ position: 0,
+ },
+ ],
+ },
+ ],
+ fields: [
+ {
+ viewFieldId: targetField.id,
+ isVisible: true,
+ position: 0,
+ },
+ ],
+ },
+ });
+
+ expect(errors).toBeDefined();
+ expect(errors.length).toBeGreaterThan(0);
+ });
+
+ it('should fail when neither groups nor fields are provided', async () => {
+ const { errors } = await upsertFieldsWidget({
+ expectToFail: true,
+ input: {
+ widgetId: testSetup.widgetId,
+ },
+ });
+
+ expect(errors).toBeDefined();
+ expect(errors.length).toBeGreaterThan(0);
+ });
+
+ it('should fail when widget id does not exist', async () => {
+ const { errors } = await upsertFieldsWidget({
+ expectToFail: true,
+ input: {
+ widgetId: uuidv4(),
+ fields: [
+ {
+ viewFieldId: testSetup.viewFields[0].id,
+ isVisible: true,
+ position: 0,
+ },
+ ],
+ },
+ });
+
+ expect(errors).toBeDefined();
+ expect(errors.length).toBeGreaterThan(0);
+ });
+ });
+
+ describe('return type', () => {
+ it('should return a view with the expected fields', async () => {
+ const targetField = testSetup.viewFields[0];
+
+ const { data } = await upsertFieldsWidget({
+ expectToFail: false,
+ input: {
+ widgetId: testSetup.widgetId,
+ fields: [
+ {
+ viewFieldId: targetField.id,
+ isVisible: true,
+ position: 0,
+ },
+ ],
+ },
+ gqlFields: VIEW_WITH_FIELDS_AND_GROUPS_GQL_FIELDS,
+ });
+
+ const view = data.upsertFieldsWidget;
+
+ expect(view.id).toBeDefined();
+ expect(view.name).toBeDefined();
+ expect(view.objectMetadataId).toBeDefined();
+ expect(view.workspaceId).toBeDefined();
+ expect(view.createdAt).toBeDefined();
+ expect(view.updatedAt).toBeDefined();
+ expect(Array.isArray(view.viewFields)).toBe(true);
+ expect(Array.isArray(view.viewFieldGroups)).toBe(true);
+ });
+ });
+});
diff --git a/packages/twenty-server/test/integration/metadata/suites/view-field-group/utils/upsert-fields-widget-query-factory.util.ts b/packages/twenty-server/test/integration/metadata/suites/view-field-group/utils/upsert-fields-widget-query-factory.util.ts
new file mode 100644
index 0000000000..15e7c4b43c
--- /dev/null
+++ b/packages/twenty-server/test/integration/metadata/suites/view-field-group/utils/upsert-fields-widget-query-factory.util.ts
@@ -0,0 +1,23 @@
+import gql from 'graphql-tag';
+import { VIEW_GQL_FIELDS } from 'test/integration/constants/view-gql-fields.constants';
+
+import { type UpsertFieldsWidgetInput } from 'src/engine/metadata-modules/view-field-group/dtos/inputs/upsert-fields-widget.input';
+
+export const upsertFieldsWidgetQueryFactory = ({
+ gqlFields = VIEW_GQL_FIELDS,
+ input,
+}: {
+ gqlFields?: string;
+ input: UpsertFieldsWidgetInput;
+}) => ({
+ query: gql`
+ mutation UpsertFieldsWidget($input: UpsertFieldsWidgetInput!) {
+ upsertFieldsWidget(input: $input) {
+ ${gqlFields}
+ }
+ }
+ `,
+ variables: {
+ input,
+ },
+});
diff --git a/packages/twenty-server/test/integration/metadata/suites/view-field-group/utils/upsert-fields-widget.util.ts b/packages/twenty-server/test/integration/metadata/suites/view-field-group/utils/upsert-fields-widget.util.ts
new file mode 100644
index 0000000000..d3dd105b02
--- /dev/null
+++ b/packages/twenty-server/test/integration/metadata/suites/view-field-group/utils/upsert-fields-widget.util.ts
@@ -0,0 +1,43 @@
+import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
+import { upsertFieldsWidgetQueryFactory } from 'test/integration/metadata/suites/view-field-group/utils/upsert-fields-widget-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 UpsertFieldsWidgetInput } from 'src/engine/metadata-modules/view-field-group/dtos/inputs/upsert-fields-widget.input';
+import { type ViewDTO } from 'src/engine/metadata-modules/view/dtos/view.dto';
+
+export const upsertFieldsWidget = async ({
+ input,
+ gqlFields,
+ expectToFail,
+}: {
+ input: UpsertFieldsWidgetInput;
+ gqlFields?: string;
+ expectToFail?: boolean | null;
+}): CommonResponseBody<{
+ upsertFieldsWidget: ViewDTO;
+}> => {
+ const graphqlOperation = upsertFieldsWidgetQueryFactory({
+ input,
+ gqlFields,
+ });
+
+ const response = await makeMetadataAPIRequest(graphqlOperation);
+
+ if (expectToFail === true) {
+ warnIfNoErrorButExpectedToFail({
+ response,
+ errorMessage: 'Upsert fields widget should have failed but did not',
+ });
+ }
+
+ if (expectToFail === false) {
+ warnIfErrorButNotExpectedToFail({
+ response,
+ errorMessage: 'Upsert fields widget has failed but should not',
+ });
+ }
+
+ return { data: response.body.data, errors: response.body.errors };
+};