Reset Tab Page Layout (#19453)

## Context
- Add "Reset to default" for page layout tabs and widgets — backend
mutation resets overrides, reactivates deactivated children, and deletes
custom children
- Fix override write bug where mutating an overridable property (e.g.
widget title) on a standard-app entity incorrectly overwrote the base
column instead of writing to the overrides JSONB —
PageLayoutUpdateService now uses resolveFlatEntityOverridableProperties
for accurate diffing and routes properties through
sanitizeOverridableEntityInput
- Deprecate isOverridden - We need more time to think about this
feature. Currently this adds too much complexity for a very small
benefit



https://github.com/user-attachments/assets/a84546c8-1e15-4d9e-a489-0825cf8b8ed2
This commit is contained in:
Weiko
2026-04-08 18:43:57 +02:00
committed by GitHub
parent 2267c56f15
commit 238018dc7b
94 changed files with 1000 additions and 619 deletions
@@ -38,7 +38,10 @@ export class ViewFieldGroupDTO {
@Field(() => [ViewFieldDTO])
viewFields?: ViewFieldDTO[];
@Field(() => Boolean, { nullable: false })
@Field(() => Boolean, {
nullable: false,
deprecationReason: 'isOverridden is deprecated',
})
isOverridden: boolean;
@HideField()
@@ -2,7 +2,6 @@ import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
import {
Args,
Context,
Float,
Mutation,
Parent,
Query,
@@ -10,7 +9,6 @@ import {
} from '@nestjs/graphql';
import { isArray } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
@@ -19,14 +17,12 @@ import { type IDataloaders } from 'src/engine/dataloaders/dataloader.interface';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { resolveOverridableEntityProperty } from 'src/engine/metadata-modules/utils/resolve-overridable-entity-property.util';
import { CreateViewFieldGroupInput } from 'src/engine/metadata-modules/view-field-group/dtos/inputs/create-view-field-group.input';
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';
@@ -43,35 +39,12 @@ export class ViewFieldGroupResolver {
private readonly fieldsWidgetUpsertService: FieldsWidgetUpsertService,
) {}
@ResolveField(() => String)
name(@Parent() viewFieldGroup: ViewFieldGroupDTO): string {
return resolveOverridableEntityProperty(viewFieldGroup, 'name');
}
@ResolveField(() => Float)
position(@Parent() viewFieldGroup: ViewFieldGroupDTO): number {
return resolveOverridableEntityProperty(viewFieldGroup, 'position');
}
@ResolveField(() => Boolean)
isVisible(@Parent() viewFieldGroup: ViewFieldGroupDTO): boolean {
return resolveOverridableEntityProperty(viewFieldGroup, 'isVisible');
}
@ResolveField(() => Boolean)
isOverridden(@Parent() viewFieldGroup: ViewFieldGroupDTO): boolean {
return (
isDefined(viewFieldGroup.overrides) &&
Object.keys(viewFieldGroup.overrides).length > 0
);
}
@Query(() => [ViewFieldGroupDTO])
@UseGuards(NoPermissionGuard)
async getViewFieldGroups(
@Args('viewId', { type: () => String }) viewId: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<ViewFieldGroupEntity[]> {
): Promise<ViewFieldGroupDTO[]> {
return this.viewFieldGroupService.findByViewId(workspace.id, viewId);
}
@@ -80,7 +53,7 @@ export class ViewFieldGroupResolver {
async getViewFieldGroup(
@Args('id', { type: () => String }) id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<ViewFieldGroupEntity | null> {
): Promise<ViewFieldGroupDTO | null> {
return this.viewFieldGroupService.findById(id, workspace.id);
}
@@ -1,11 +1,10 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { IsNull, Repository } from 'typeorm';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { findFlatEntityByUniversalIdentifierOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier-or-throw.util';
import { findManyFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-many-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { fromCreateViewFieldGroupInputToFlatViewFieldGroupToCreate } from 'src/engine/metadata-modules/flat-view-field-group/utils/from-create-view-field-group-input-to-flat-view-field-group-to-create.util';
@@ -17,7 +16,6 @@ import { DeleteViewFieldGroupInput } from 'src/engine/metadata-modules/view-fiel
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 { 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 {
ViewFieldGroupException,
ViewFieldGroupExceptionCode,
@@ -31,8 +29,6 @@ export class ViewFieldGroupService {
constructor(
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
@InjectRepository(ViewFieldGroupEntity)
private readonly viewFieldGroupRepository: Repository<ViewFieldGroupEntity>,
private readonly applicationService: ApplicationService,
) {}
@@ -342,30 +338,51 @@ export class ViewFieldGroupService {
async findByViewId(
workspaceId: string,
viewId: string,
): Promise<ViewFieldGroupEntity[]> {
return this.viewFieldGroupRepository.find({
where: {
workspaceId,
viewId,
isActive: true,
deletedAt: IsNull(),
},
order: { position: 'ASC' },
});
): Promise<ViewFieldGroupDTO[]> {
const { flatViewFieldGroupMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatViewFieldGroupMaps'],
},
);
return Object.values(flatViewFieldGroupMaps.byUniversalIdentifier)
.filter(isDefined)
.filter(
(group) =>
group.viewId === viewId &&
group.isActive &&
!isDefined(group.deletedAt),
)
.map(fromFlatViewFieldGroupToViewFieldGroupDto)
.sort((a, b) => a.position - b.position);
}
async findById(
id: string,
workspaceId: string,
): Promise<ViewFieldGroupEntity | null> {
const viewFieldGroup = await this.viewFieldGroupRepository.findOne({
where: {
id,
workspaceId,
deletedAt: IsNull(),
},
): Promise<ViewFieldGroupDTO | null> {
const { flatViewFieldGroupMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatViewFieldGroupMaps'],
},
);
const flatViewFieldGroup = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: id,
flatEntityMaps: flatViewFieldGroupMaps,
});
return viewFieldGroup || null;
if (
!isDefined(flatViewFieldGroup) ||
isDefined(flatViewFieldGroup.deletedAt)
) {
return null;
}
return fromFlatViewFieldGroupToViewFieldGroupDto(flatViewFieldGroup);
}
}
@@ -1,17 +1,16 @@
import { isDefined } from 'twenty-shared/utils';
import { type FlatViewFieldGroup } from 'src/engine/metadata-modules/flat-view-field-group/types/flat-view-field-group.type';
import { type ViewFieldGroupDTO } from 'src/engine/metadata-modules/view-field-group/dtos/view-field-group.dto';
export const fromFlatViewFieldGroupToViewFieldGroupDto = (
flatViewFieldGroup: FlatViewFieldGroup,
): ViewFieldGroupDTO => {
const { createdAt, updatedAt, deletedAt, ...rest } = flatViewFieldGroup;
const { createdAt, updatedAt, deletedAt, overrides, ...rest } =
flatViewFieldGroup;
return {
...rest,
isOverridden:
isDefined(rest.overrides) && Object.keys(rest.overrides).length > 0,
...(overrides ?? {}),
isOverridden: false,
createdAt: new Date(createdAt),
updatedAt: new Date(updatedAt),
deletedAt: deletedAt ? new Date(deletedAt) : null,