Reset Fields widget implementation (#19283)

## Context
This PR implements the first steps for overridable entities resets.

<img width="1277" height="568" alt="Screenshot 2026-04-02 at 18 58 04"
src="https://github.com/user-attachments/assets/4c7f93b1-c453-4905-a919-cd6af11e0e16"
/>
This commit is contained in:
Weiko
2026-04-07 11:34:44 +02:00
committed by GitHub
parent 23874848a4
commit a2188cb0eb
11 changed files with 528 additions and 0 deletions
@@ -0,0 +1,45 @@
type EntityWithApplicationIdentifierAndOverrides = {
applicationUniversalIdentifier: string;
isActive: boolean;
overrides: unknown;
};
export const splitEntitiesByResetStrategy = <
T extends EntityWithApplicationIdentifierAndOverrides,
>({
entities,
workspaceCustomApplicationUniversalIdentifier,
now,
}: {
entities: T[];
workspaceCustomApplicationUniversalIdentifier: string;
now: string;
}): {
toHardDelete: T[];
toReset: (T & { isActive: true; overrides: null; updatedAt: string })[];
} => {
const toHardDelete: T[] = [];
const toReset: (T & {
isActive: true;
overrides: null;
updatedAt: string;
})[] = [];
for (const entity of entities) {
if (
entity.applicationUniversalIdentifier ===
workspaceCustomApplicationUniversalIdentifier
) {
toHardDelete.push(entity);
} else {
toReset.push({
...entity,
isActive: true as const,
overrides: null,
updatedAt: now,
});
}
}
return { toHardDelete, toReset };
};
@@ -13,6 +13,7 @@ import { PageLayoutController } from 'src/engine/metadata-modules/page-layout/co
import { PageLayoutEntity } from 'src/engine/metadata-modules/page-layout/entities/page-layout.entity';
import { PageLayoutResolver } from 'src/engine/metadata-modules/page-layout/resolvers/page-layout.resolver';
import { PageLayoutDuplicationService } from 'src/engine/metadata-modules/page-layout/services/page-layout-duplication.service';
import { PageLayoutResetService } from 'src/engine/metadata-modules/page-layout/services/page-layout-reset.service';
import { PageLayoutUpdateService } from 'src/engine/metadata-modules/page-layout/services/page-layout-update.service';
import { PageLayoutService } from 'src/engine/metadata-modules/page-layout/services/page-layout.service';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
@@ -45,6 +46,7 @@ import { DashboardSyncModule } from 'src/modules/dashboard-sync/dashboard-sync.m
PageLayoutService,
PageLayoutDuplicationService,
PageLayoutResolver,
PageLayoutResetService,
PageLayoutUpdateService,
WorkspaceMigrationGraphqlApiExceptionInterceptor,
],
@@ -15,11 +15,13 @@ import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorato
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { PageLayoutWidgetDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/page-layout-widget.dto';
import { CreatePageLayoutInput } from 'src/engine/metadata-modules/page-layout/dtos/inputs/create-page-layout.input';
import { UpdatePageLayoutWithTabsInput } from 'src/engine/metadata-modules/page-layout/dtos/inputs/update-page-layout-with-tabs.input';
import { UpdatePageLayoutInput } from 'src/engine/metadata-modules/page-layout/dtos/inputs/update-page-layout.input';
import { PageLayoutDTO } from 'src/engine/metadata-modules/page-layout/dtos/page-layout.dto';
import { PageLayoutType } from 'src/engine/metadata-modules/page-layout/enums/page-layout-type.enum';
import { PageLayoutResetService } from 'src/engine/metadata-modules/page-layout/services/page-layout-reset.service';
import { PageLayoutUpdateService } from 'src/engine/metadata-modules/page-layout/services/page-layout-update.service';
import { PageLayoutService } from 'src/engine/metadata-modules/page-layout/services/page-layout.service';
import { PageLayoutGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/page-layout/utils/page-layout-graphql-api-exception.filter';
@@ -34,6 +36,7 @@ export class PageLayoutResolver {
constructor(
private readonly pageLayoutService: PageLayoutService,
private readonly pageLayoutUpdateService: PageLayoutUpdateService,
private readonly pageLayoutResetService: PageLayoutResetService,
) {}
@Query(() => [PageLayoutDTO])
@@ -121,4 +124,16 @@ export class PageLayoutResolver {
input,
});
}
@Mutation(() => PageLayoutWidgetDTO)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.LAYOUTS))
async resetPageLayoutWidgetToDefault(
@Args('id', { type: () => String }) id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<PageLayoutWidgetDTO> {
return this.pageLayoutResetService.resetPageLayoutWidgetToDefault({
id,
workspaceId: workspace.id,
});
}
}
@@ -0,0 +1,254 @@
import { Injectable, Logger } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
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 { splitEntitiesByResetStrategy } from 'src/engine/metadata-modules/flat-entity/utils/split-entities-by-reset-strategy.util';
import { type FlatPageLayoutWidget } from 'src/engine/metadata-modules/flat-page-layout-widget/types/flat-page-layout-widget.type';
import { isFlatPageLayoutWidgetConfigurationOfType } from 'src/engine/metadata-modules/flat-page-layout-widget/utils/is-flat-page-layout-widget-configuration-of-type.util';
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 { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-configuration-type.type';
import {
PageLayoutWidgetException,
PageLayoutWidgetExceptionCode,
PageLayoutWidgetExceptionMessageKey,
generatePageLayoutWidgetExceptionMessage,
} from 'src/engine/metadata-modules/page-layout-widget/exceptions/page-layout-widget.exception';
import { type PageLayoutWidgetDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/page-layout-widget.dto';
import { fromFlatPageLayoutWidgetToPageLayoutWidgetDto } from 'src/engine/metadata-modules/page-layout-widget/utils/from-flat-page-layout-widget-to-page-layout-widget-dto.util';
import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
import { DashboardSyncService } from 'src/modules/dashboard-sync/services/dashboard-sync.service';
@Injectable()
export class PageLayoutResetService {
private readonly logger = new Logger(PageLayoutResetService.name);
constructor(
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly applicationService: ApplicationService,
private readonly dashboardSyncService: DashboardSyncService,
) {}
async resetPageLayoutWidgetToDefault({
id,
workspaceId,
}: {
id: string;
workspaceId: string;
}): Promise<PageLayoutWidgetDTO> {
const {
flatPageLayoutWidgetMaps,
flatViewFieldGroupMaps,
flatViewFieldMaps,
} =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: [
'flatPageLayoutWidgetMaps',
'flatViewFieldGroupMaps',
'flatViewFieldMaps',
],
},
);
const widget = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: id,
flatEntityMaps: flatPageLayoutWidgetMaps,
});
if (!isDefined(widget) || isDefined(widget.deletedAt)) {
throw new PageLayoutWidgetException(
generatePageLayoutWidgetExceptionMessage(
PageLayoutWidgetExceptionMessageKey.PAGE_LAYOUT_WIDGET_NOT_FOUND,
id,
),
PageLayoutWidgetExceptionCode.PAGE_LAYOUT_WIDGET_NOT_FOUND,
);
}
if (
!isFlatPageLayoutWidgetConfigurationOfType(
widget,
WidgetConfigurationType.FIELDS,
)
) {
throw new PageLayoutWidgetException(
`Widget "${id}" is not a FIELDS widget and cannot be reset to default`,
PageLayoutWidgetExceptionCode.INVALID_PAGE_LAYOUT_WIDGET_DATA,
);
}
const { workspaceCustomFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
if (
widget.applicationUniversalIdentifier ===
workspaceCustomFlatApplication.universalIdentifier
) {
throw new PageLayoutWidgetException(
`Custom widget "${id}" cannot be reset to default`,
PageLayoutWidgetExceptionCode.INVALID_PAGE_LAYOUT_WIDGET_DATA,
);
}
const now = new Date().toISOString();
const widgetToUpdate: FlatPageLayoutWidget = {
...widget,
overrides: null,
updatedAt: now,
};
const viewId = widget.configuration.viewId;
const {
viewFieldGroupsToUpdate,
viewFieldGroupsToDelete,
viewFieldsToUpdate,
viewFieldsToDelete,
} = isDefined(viewId)
? this.computeFieldsWidgetChildResetOperations({
viewId,
flatViewFieldGroupMaps,
flatViewFieldMaps,
workspaceCustomApplicationUniversalIdentifier:
workspaceCustomFlatApplication.universalIdentifier,
now,
})
: {
viewFieldGroupsToUpdate: [],
viewFieldGroupsToDelete: [],
viewFieldsToUpdate: [],
viewFieldsToDelete: [],
};
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
pageLayoutWidget: {
flatEntityToCreate: [],
flatEntityToUpdate: [widgetToUpdate],
flatEntityToDelete: [],
},
viewFieldGroup: {
flatEntityToCreate: [],
flatEntityToUpdate: viewFieldGroupsToUpdate,
flatEntityToDelete: viewFieldGroupsToDelete,
},
viewField: {
flatEntityToCreate: [],
flatEntityToUpdate: viewFieldsToUpdate,
flatEntityToDelete: viewFieldsToDelete,
},
},
workspaceId,
isSystemBuild: false,
applicationUniversalIdentifier:
workspaceCustomFlatApplication.universalIdentifier,
},
);
if (validateAndBuildResult.status === 'fail') {
throw new WorkspaceMigrationBuilderException(
validateAndBuildResult,
'Multiple validation errors occurred while resetting page layout widget to default',
);
}
const { flatPageLayoutWidgetMaps: recomputedWidgetMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatPageLayoutWidgetMaps'],
},
);
const updatedWidget = findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: id,
flatEntityMaps: recomputedWidgetMaps,
});
await this.dashboardSyncService.updateLinkedDashboardsUpdatedAtByWidgetId({
widgetId: id,
workspaceId,
updatedAt: new Date(updatedWidget.updatedAt),
});
return fromFlatPageLayoutWidgetToPageLayoutWidgetDto(updatedWidget);
}
private computeFieldsWidgetChildResetOperations({
viewId,
flatViewFieldGroupMaps,
flatViewFieldMaps,
workspaceCustomApplicationUniversalIdentifier,
now,
}: {
viewId: string;
flatViewFieldGroupMaps: {
byUniversalIdentifier: Record<string, FlatViewFieldGroup | undefined>;
};
flatViewFieldMaps: {
byUniversalIdentifier: Record<string, FlatViewField | undefined>;
};
workspaceCustomApplicationUniversalIdentifier: string;
now: string;
}): {
viewFieldGroupsToUpdate: FlatViewFieldGroup[];
viewFieldGroupsToDelete: FlatViewFieldGroup[];
viewFieldsToUpdate: FlatViewField[];
viewFieldsToDelete: FlatViewField[];
} {
const existingGroups = Object.values(
flatViewFieldGroupMaps.byUniversalIdentifier,
)
.filter(isDefined)
.filter(
(group) => group.viewId === viewId && !isDefined(group.deletedAt),
);
const existingFields = Object.values(
flatViewFieldMaps.byUniversalIdentifier,
)
.filter(isDefined)
.filter(
(field) => field.viewId === viewId && !isDefined(field.deletedAt),
);
const { toHardDelete: groupsToDelete, toReset: groupsToReset } =
splitEntitiesByResetStrategy({
entities: existingGroups,
workspaceCustomApplicationUniversalIdentifier,
now,
});
const { toHardDelete: fieldsToDelete, toReset: fieldsToReset } =
splitEntitiesByResetStrategy({
entities: existingFields,
workspaceCustomApplicationUniversalIdentifier,
now,
});
const viewFieldsToReset = fieldsToReset.map((field) => ({
...field,
universalOverrides: null,
}));
return {
viewFieldGroupsToUpdate: groupsToReset,
viewFieldGroupsToDelete: groupsToDelete,
viewFieldsToUpdate: viewFieldsToReset,
viewFieldsToDelete: fieldsToDelete,
};
}
}