Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { VIEW_FIELD_GROUP_FRAGMENT } from '@/views/graphql/fragments/viewFieldGroupFragment';
import { gql } from '@apollo/client';

export const UPSERT_FIELDS_WIDGET = gql`
${VIEW_FIELD_GROUP_FRAGMENT}
mutation UpsertFieldsWidget($input: UpsertFieldsWidgetInput!) {
upsertFieldsWidget(input: $input) {
...ViewFieldGroupFragment
}
}
`;
Original file line number Diff line number Diff line change
@@ -1,18 +1,31 @@
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 { UPSERT_FIELDS_WIDGET } from '@/page-layout/graphql/mutations/upsertFieldsWidget';
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 CoreViewFieldGroup } from '~/generated-metadata/graphql';

type UpsertFieldsWidgetInput = {
widgetId: string;
groups: {
id: string;
name: string;
position: number;
isVisible: boolean;
fields: {
viewFieldId: string;
isVisible: boolean;
position: number;
}[];
}[];
};

type UpsertFieldsWidgetResult = {
upsertFieldsWidget: CoreViewFieldGroup[];
};

type UseSaveFieldsWidgetGroupsParams = {
pageLayoutId: string;
Expand All @@ -31,52 +44,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 () => {
Expand All @@ -94,73 +68,33 @@ export const useSaveFieldsWidgetGroups = ({

for (const widgetId of widgetIds) {
const draftGroups = allDraftGroups[widgetId] ?? [];
const persistedGroups = allPersistedGroups[widgetId] ?? [];

if (draftGroups.length === 0 && persistedGroups.length === 0) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can't find this logic anymore. Just want to make sure that we are not calling mutations for unchanged groups 🤔

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you click on the Save button of a dashboard, it triggers a mutation even if you changed nothing. The behavior is similar for record page layouts. I think we can go this way.

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,
},
];
}),
Comment on lines +86 to +98
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using flatMap with isDefined check correctly filters out fields without viewFieldId, preventing undefined values from being sent to the backend

})),
},
}));

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);
Comment on lines 74 to 105
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The mutation loop lacks error handling. If a mutation fails partway through, the persisted state is still updated on line 100, potentially causing inconsistency between frontend state and backend state. Consider wrapping the loop in try-catch and only updating persisted state if all mutations succeed, or handle partial failures appropriately.

Copilot uses AI. Check for mistakes.
Expand All @@ -172,11 +106,7 @@ export const useSaveFieldsWidgetGroups = ({
[
fieldsWidgetGroupsDraftState,
fieldsWidgetGroupsPersistedState,
getViewIdForWidget,
performViewFieldGroupAPICreate,
performViewFieldGroupAPIDelete,
performViewFieldGroupAPIUpdate,
performViewFieldAPIUpdate,
upsertFieldsWidgetMutation,
refreshAllCoreViews,
],
);
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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[];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Field, InputType } from '@nestjs/graphql';

import { Type } from 'class-transformer';
import { IsNotEmpty, IsUUID, ValidateNested } from 'class-validator';

import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
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;

@ValidateNested({ each: true })
@Type(() => UpsertFieldsWidgetGroupInput)
@Field(() => [UpsertFieldsWidgetGroupInput])
groups: UpsertFieldsWidgetGroupInput[];
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what about fields widget associated with views that don't have groups? I believe this is optional

}
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,6 @@ export class ViewFieldGroupException extends CustomException<ViewFieldGroupExcep
export enum ViewFieldGroupExceptionCode {
VIEW_FIELD_GROUP_NOT_FOUND = 'VIEW_FIELD_GROUP_NOT_FOUND',
VIEW_NOT_FOUND = 'VIEW_NOT_FOUND',
FIELDS_WIDGET_NOT_FOUND = 'FIELDS_WIDGET_NOT_FOUND',
INVALID_VIEW_FIELD_GROUP_DATA = 'INVALID_VIEW_FIELD_GROUP_DATA',
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,10 @@ import { CreateViewFieldGroupInput } from 'src/engine/metadata-modules/view-fiel
import { DeleteViewFieldGroupInput } from 'src/engine/metadata-modules/view-field-group/dtos/inputs/delete-view-field-group.input';
import { DestroyViewFieldGroupInput } from 'src/engine/metadata-modules/view-field-group/dtos/inputs/destroy-view-field-group.input';
import { UpdateViewFieldGroupInput } from 'src/engine/metadata-modules/view-field-group/dtos/inputs/update-view-field-group.input';
import { UpsertFieldsWidgetInput } from 'src/engine/metadata-modules/view-field-group/dtos/inputs/upsert-fields-widget.input';
import { ViewFieldGroupDTO } from 'src/engine/metadata-modules/view-field-group/dtos/view-field-group.dto';
import { ViewFieldGroupEntity } from 'src/engine/metadata-modules/view-field-group/entities/view-field-group.entity';
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 { ViewFieldDTO } from 'src/engine/metadata-modules/view-field/dtos/view-field.dto';
import { ViewGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/view/utils/view-graphql-api-exception.filter';
Expand All @@ -30,7 +32,10 @@ import { ViewGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/view/
@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)
Expand Down Expand Up @@ -113,6 +118,18 @@ export class ViewFieldGroupResolver {
});
}

@Mutation(() => [ViewFieldGroupDTO])
@UseGuards(NoPermissionGuard)
async upsertFieldsWidget(
@Args('input') input: UpsertFieldsWidgetInput,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
): Promise<ViewFieldGroupDTO[]> {
return await this.fieldsWidgetUpsertService.upsertFieldsWidget({
input,
workspaceId,
});
}

@ResolveField(() => [ViewFieldDTO])
async viewFields(
@Parent() viewFieldGroup: ViewFieldGroupDTO,
Expand Down
Loading
Loading