Introducing view field group syncable entity (#17867)

## Context
Introduces a new viewFieldGroup entity that allows grouping view fields
into sections (e.g. "General", "Additional", "Other") within a view.

The page layout fields widget needs a way to organize fields into
sections. Today, views have no concept of field grouping. This PR
introduces the viewFieldGroup entity which sits between a view and its
viewFields, enabling section-based organization.

<img width="401" height="724" alt="Layout - V2 (customize visibility)"
src="https://github.com/user-attachments/assets/6376e2ab-44db-42bf-9d2c-758f56f6b548"
/>

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Weiko
2026-02-11 22:02:58 +01:00
committed by GitHub
parent ed66fbd71b
commit 6aca1dd013
126 changed files with 3593 additions and 115 deletions
@@ -4,6 +4,7 @@ import { IDField } from '@ptc-org/nestjs-query-graphql';
import { AggregateOperations } from 'src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { ViewFieldGroupDTO } from 'src/engine/metadata-modules/view-field-group/dtos/view-field-group.dto';
import { ViewFieldDTO } from 'src/engine/metadata-modules/view-field/dtos/view-field.dto';
import { ViewFilterGroupDTO } from 'src/engine/metadata-modules/view-filter-group/dtos/view-filter-group.dto';
import { ViewFilterDTO } from 'src/engine/metadata-modules/view-filter/dtos/view-filter.dto';
@@ -104,6 +105,9 @@ export class ViewDTO {
@Field(() => [ViewGroupDTO])
viewGroups?: ViewGroupDTO[];
@Field(() => [ViewFieldGroupDTO])
viewFieldGroups?: ViewFieldGroupDTO[];
@Field(() => ViewVisibility, {
nullable: false,
})
@@ -17,6 +17,7 @@ import { AggregateOperations } from 'src/engine/api/graphql/graphql-query-runner
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { ViewFieldGroupEntity } from 'src/engine/metadata-modules/view-field-group/entities/view-field-group.entity';
import { ViewFieldEntity } from 'src/engine/metadata-modules/view-field/entities/view-field.entity';
import { ViewFilterGroupEntity } from 'src/engine/metadata-modules/view-filter-group/entities/view-filter-group.entity';
import { ViewFilterEntity } from 'src/engine/metadata-modules/view-filter/entities/view-filter.entity';
@@ -191,6 +192,12 @@ export class ViewEntity extends SyncableEntity implements Required<ViewEntity> {
@OneToMany(() => ViewFieldEntity, (viewField) => viewField.view)
viewFields: Relation<ViewFieldEntity[]>;
@OneToMany(
() => ViewFieldGroupEntity,
(viewFieldGroup) => viewFieldGroup.view,
)
viewFieldGroups: Relation<ViewFieldGroupEntity[]>;
@OneToMany(() => ViewFilterEntity, (viewFilter) => viewFilter.view)
viewFilters: Relation<ViewFilterEntity[]>;
@@ -11,19 +11,20 @@ import {
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 { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
import { type I18nContext } from 'src/engine/core-modules/i18n/types/i18n-context.type';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { type IDataloaders } from 'src/engine/dataloaders/dataloader.interface';
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { CustomPermissionGuard } from 'src/engine/guards/custom-permission.guard';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { resolveObjectMetadataStandardOverride } from 'src/engine/metadata-modules/object-metadata/utils/resolve-object-metadata-standard-override.util';
import { ViewFieldGroupDTO } from 'src/engine/metadata-modules/view-field-group/dtos/view-field-group.dto';
import { ViewFieldDTO } from 'src/engine/metadata-modules/view-field/dtos/view-field.dto';
import { ViewFieldV2Service } from 'src/engine/metadata-modules/view-field/services/view-field-v2.service';
import { ViewFieldService } from 'src/engine/metadata-modules/view-field/services/view-field.service';
import { ViewFilterGroupDTO } from 'src/engine/metadata-modules/view-filter-group/dtos/view-filter-group.dto';
import { ViewFilterGroupService } from 'src/engine/metadata-modules/view-filter-group/services/view-filter-group.service';
import { ViewFilterDTO } from 'src/engine/metadata-modules/view-filter/dtos/view-filter.dto';
@@ -56,7 +57,7 @@ export class ViewResolver {
private readonly viewGroupService: ViewGroupService,
private readonly i18nService: I18nService,
private readonly viewFieldV2Service: ViewFieldV2Service,
private readonly viewFieldService: ViewFieldService,
) {}
@ResolveField(() => String)
@@ -209,7 +210,7 @@ export class ViewResolver {
return view.viewFields;
}
return this.viewFieldV2Service.findByViewId(workspace.id, view.id);
return this.viewFieldService.findByViewId(workspace.id, view.id);
}
@ResolveField(() => [ViewFilterDTO])
@@ -259,4 +260,20 @@ export class ViewResolver {
return this.viewGroupService.findByViewId(workspace.id, view.id);
}
@ResolveField(() => [ViewFieldGroupDTO])
async viewFieldGroups(
@Parent() view: ViewDTO,
@Context() context: { loaders: IDataloaders },
@AuthWorkspace() workspace: WorkspaceEntity,
) {
if (isArray(view.viewFieldGroups)) {
return view.viewFieldGroups;
}
return context.loaders.viewFieldGroupsByViewIdLoader.load({
workspaceId: workspace.id,
viewId: view.id,
});
}
}
@@ -7,6 +7,10 @@ import {
NotFoundError,
UserInputError,
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import {
ViewFieldGroupException,
ViewFieldGroupExceptionCode,
} from 'src/engine/metadata-modules/view-field-group/exceptions/view-field-group.exception';
import {
ViewFieldException,
ViewFieldExceptionCode,
@@ -77,6 +81,22 @@ export const viewGraphqlApiExceptionHandler = (error: Error, i18n: I18n) => {
}
}
if (error instanceof ViewFieldGroupException) {
switch (error.code) {
case ViewFieldGroupExceptionCode.VIEW_FIELD_GROUP_NOT_FOUND:
throw new NotFoundError(error.message);
case ViewFieldGroupExceptionCode.VIEW_NOT_FOUND:
throw new NotFoundError(error.message);
case ViewFieldGroupExceptionCode.INVALID_VIEW_FIELD_GROUP_DATA:
throw new UserInputError(error.message, {
userFriendlyMessage: error.userFriendlyMessage,
});
default: {
return assertUnreachable(error.code);
}
}
}
if (error instanceof ViewFilterException) {
switch (error.code) {
case ViewFilterExceptionCode.VIEW_FILTER_NOT_FOUND:
@@ -9,6 +9,7 @@ import { GqlExecutionContext } from '@nestjs/graphql';
import { SOURCE_LOCALE } from 'twenty-shared/translations';
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
import { ViewFieldGroupException } from 'src/engine/metadata-modules/view-field-group/exceptions/view-field-group.exception';
import { ViewFieldException } from 'src/engine/metadata-modules/view-field/exceptions/view-field.exception';
import { ViewFilterGroupException } from 'src/engine/metadata-modules/view-filter-group/exceptions/view-filter-group.exception';
import { ViewFilterException } from 'src/engine/metadata-modules/view-filter/exceptions/view-filter.exception';
@@ -35,6 +36,7 @@ export class ViewGraphqlApiExceptionFilter implements ExceptionFilter {
exception:
| ViewException
| ViewFieldException
| ViewFieldGroupException
| ViewFilterException
| ViewFilterGroupException
| ViewGroupException
@@ -7,6 +7,7 @@ import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata
import { FlatViewModule } from 'src/engine/metadata-modules/flat-view/flat-view.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
import { ViewFieldGroupModule } from 'src/engine/metadata-modules/view-field-group/view-field-group.module';
import { ViewFieldModule } from 'src/engine/metadata-modules/view-field/view-field.module';
import { ViewFilterGroupModule } from 'src/engine/metadata-modules/view-filter-group/view-filter-group.module';
import { ViewFilterModule } from 'src/engine/metadata-modules/view-filter/view-filter.module';
@@ -27,6 +28,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
TypeOrmModule.forFeature([ViewEntity]),
ViewPermissionsModule,
ViewFieldModule,
ViewFieldGroupModule,
ViewFilterModule,
ViewFilterGroupModule,
ViewGroupModule,