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
@@ -29,7 +29,7 @@ import {
ViewFieldExceptionMessageKey,
} from 'src/engine/metadata-modules/view-field/exceptions/view-field.exception';
import { ViewFieldRestApiExceptionFilter } from 'src/engine/metadata-modules/view-field/filters/view-field-rest-api-exception.filter';
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 { CreateViewFieldPermissionGuard } from 'src/engine/metadata-modules/view-permissions/guards/create-view-field-permission.guard';
import { DeleteViewFieldPermissionGuard } from 'src/engine/metadata-modules/view-permissions/guards/delete-view-field-permission.guard';
import { UpdateViewFieldPermissionGuard } from 'src/engine/metadata-modules/view-permissions/guards/update-view-field-permission.guard';
@@ -38,7 +38,7 @@ import { UpdateViewFieldPermissionGuard } from 'src/engine/metadata-modules/view
@UseGuards(WorkspaceAuthGuard)
@UseFilters(ViewFieldRestApiExceptionFilter)
export class ViewFieldController {
constructor(private readonly viewFieldV2Service: ViewFieldV2Service) {}
constructor(private readonly viewFieldService: ViewFieldService) {}
@Get()
@UseGuards(NoPermissionGuard)
@@ -47,10 +47,10 @@ export class ViewFieldController {
@Query('viewId') viewId?: string,
): Promise<ViewFieldEntity[]> {
if (viewId) {
return this.viewFieldV2Service.findByViewId(workspace.id, viewId);
return this.viewFieldService.findByViewId(workspace.id, viewId);
}
return this.viewFieldV2Service.findByWorkspaceId(workspace.id);
return this.viewFieldService.findByWorkspaceId(workspace.id);
}
@Get(':id')
@@ -59,7 +59,7 @@ export class ViewFieldController {
@Param('id') id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<ViewFieldEntity> {
const viewField = await this.viewFieldV2Service.findById(id, workspace.id);
const viewField = await this.viewFieldService.findById(id, workspace.id);
if (!isDefined(viewField)) {
throw new ViewFieldException(
@@ -86,7 +86,7 @@ export class ViewFieldController {
@Body() input: UpdateViewFieldInput['update'],
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<ViewFieldDTO> {
return await this.viewFieldV2Service.updateOne({
return await this.viewFieldService.updateOne({
updateViewFieldInput: { id, update: input },
workspaceId: workspace.id,
});
@@ -98,7 +98,7 @@ export class ViewFieldController {
@Body() input: CreateViewFieldInput,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<ViewFieldDTO> {
return await this.viewFieldV2Service.createOne({
return await this.viewFieldService.createOne({
createViewFieldInput: input,
workspaceId: workspace.id,
});
@@ -110,7 +110,7 @@ export class ViewFieldController {
@Param('id') id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<{ success: boolean }> {
const deletedViewField = await this.viewFieldV2Service.deleteOne({
const deletedViewField = await this.viewFieldService.deleteOne({
deleteViewFieldInput: { id },
workspaceId: workspace.id,
});
@@ -46,6 +46,11 @@ export class CreateViewFieldInput {
@Field(() => AggregateOperations, { nullable: true })
aggregateOperation?: AggregateOperations;
@IsOptional()
@IsUUID()
@Field(() => UUIDScalarType, { nullable: true })
viewFieldGroupId?: string;
@HideField()
universalIdentifier?: string;
@@ -35,6 +35,11 @@ class UpdateViewFieldInputUpdates {
@IsEnum(AggregateOperations)
@Field(() => AggregateOperations, { nullable: true })
aggregateOperation?: AggregateOperations;
@IsOptional()
@IsUUID()
@Field(() => UUIDScalarType, { nullable: true })
viewFieldGroupId?: string | null;
}
@InputType()
@@ -13,6 +13,7 @@ import {
import { AggregateOperations } from 'src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { ViewFieldGroupEntity } from 'src/engine/metadata-modules/view-field-group/entities/view-field-group.entity';
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-entity.interface';
@@ -64,6 +65,9 @@ export class ViewFieldEntity
@Column({ nullable: false, type: 'uuid' })
viewId: string;
@Column({ nullable: true, type: 'uuid' })
viewFieldGroupId: string | null;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@@ -78,4 +82,15 @@ export class ViewFieldEntity
})
@JoinColumn({ name: 'viewId' })
view: Relation<ViewEntity>;
@ManyToOne(
() => ViewFieldGroupEntity,
(viewFieldGroup) => viewFieldGroup.viewFields,
{
onDelete: 'SET NULL',
nullable: true,
},
)
@JoinColumn({ name: 'viewFieldGroupId' })
viewFieldGroup: Relation<ViewFieldGroupEntity> | null;
}
@@ -1,9 +1,9 @@
import { UseFilters, UseGuards } from '@nestjs/common';
import { Args, Mutation, Query } from '@nestjs/graphql';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { CreateViewFieldInput } from 'src/engine/metadata-modules/view-field/dtos/inputs/create-view-field.input';
@@ -12,7 +12,7 @@ import { DestroyViewFieldInput } from 'src/engine/metadata-modules/view-field/dt
import { UpdateViewFieldInput } from 'src/engine/metadata-modules/view-field/dtos/inputs/update-view-field.input';
import { ViewFieldDTO } from 'src/engine/metadata-modules/view-field/dtos/view-field.dto';
import { ViewFieldEntity } from 'src/engine/metadata-modules/view-field/entities/view-field.entity';
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 { CreateViewFieldPermissionGuard } from 'src/engine/metadata-modules/view-permissions/guards/create-view-field-permission.guard';
import { DeleteViewFieldPermissionGuard } from 'src/engine/metadata-modules/view-permissions/guards/delete-view-field-permission.guard';
import { DestroyViewFieldPermissionGuard } from 'src/engine/metadata-modules/view-permissions/guards/destroy-view-field-permission.guard';
@@ -23,7 +23,7 @@ import { ViewGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/view/
@UseFilters(ViewGraphqlApiExceptionFilter)
@UseGuards(WorkspaceAuthGuard)
export class ViewFieldResolver {
constructor(private readonly viewFieldV2Service: ViewFieldV2Service) {}
constructor(private readonly viewFieldService: ViewFieldService) {}
@Query(() => [ViewFieldDTO])
@UseGuards(NoPermissionGuard)
@@ -31,7 +31,7 @@ export class ViewFieldResolver {
@Args('viewId', { type: () => String }) viewId: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<ViewFieldEntity[]> {
return this.viewFieldV2Service.findByViewId(workspace.id, viewId);
return this.viewFieldService.findByViewId(workspace.id, viewId);
}
@Query(() => ViewFieldDTO, { nullable: true })
@@ -40,7 +40,7 @@ export class ViewFieldResolver {
@Args('id', { type: () => String }) id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<ViewFieldEntity | null> {
return this.viewFieldV2Service.findById(id, workspace.id);
return this.viewFieldService.findById(id, workspace.id);
}
@Mutation(() => ViewFieldDTO)
@@ -49,7 +49,7 @@ export class ViewFieldResolver {
@Args('input') updateViewFieldInput: UpdateViewFieldInput,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
): Promise<ViewFieldDTO> {
return await this.viewFieldV2Service.updateOne({
return await this.viewFieldService.updateOne({
updateViewFieldInput,
workspaceId,
});
@@ -61,7 +61,7 @@ export class ViewFieldResolver {
@Args('input') createViewFieldInput: CreateViewFieldInput,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
): Promise<ViewFieldDTO> {
return await this.viewFieldV2Service.createOne({
return await this.viewFieldService.createOne({
createViewFieldInput,
workspaceId,
});
@@ -74,7 +74,7 @@ export class ViewFieldResolver {
createViewFieldInputs: CreateViewFieldInput[],
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
): Promise<ViewFieldDTO[]> {
return await this.viewFieldV2Service.createMany({
return await this.viewFieldService.createMany({
createViewFieldInputs,
workspaceId,
});
@@ -86,7 +86,7 @@ export class ViewFieldResolver {
@Args('input') deleteViewFieldInput: DeleteViewFieldInput,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
): Promise<ViewFieldDTO> {
return await this.viewFieldV2Service.deleteOne({
return await this.viewFieldService.deleteOne({
deleteViewFieldInput,
workspaceId,
});
@@ -98,7 +98,7 @@ export class ViewFieldResolver {
@Args('input') destroyViewFieldInput: DestroyViewFieldInput,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
): Promise<ViewFieldDTO> {
return await this.viewFieldV2Service.destroyOne({
return await this.viewFieldService.destroyOne({
destroyViewFieldInput,
workspaceId,
});
@@ -27,7 +27,7 @@ import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
@Injectable()
export class ViewFieldV2Service {
export class ViewFieldService {
constructor(
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
@@ -76,11 +76,15 @@ export class ViewFieldV2Service {
},
);
const { flatFieldMetadataMaps, flatViewMaps } =
const { flatFieldMetadataMaps, flatViewMaps, flatViewFieldGroupMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatFieldMetadataMaps', 'flatViewMaps'],
flatMapsKeys: [
'flatFieldMetadataMaps',
'flatViewMaps',
'flatViewFieldGroupMaps',
],
},
);
@@ -91,6 +95,7 @@ export class ViewFieldV2Service {
flatApplication: workspaceCustomFlatApplication,
flatFieldMetadataMaps,
flatViewMaps,
flatViewFieldGroupMaps,
}),
);
@@ -146,17 +151,21 @@ export class ViewFieldV2Service {
},
);
const { flatViewFieldMaps: existingFlatViewFieldMaps } =
const {
flatViewFieldMaps: existingFlatViewFieldMaps,
flatViewFieldGroupMaps,
} =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatViewFieldMaps'],
flatMapsKeys: ['flatViewFieldMaps', 'flatViewFieldGroupMaps'],
},
);
const optimisticallyUpdatedFlatView =
fromUpdateViewFieldInputToFlatViewFieldToUpdateOrThrow({
flatViewFieldMaps: existingFlatViewFieldMaps,
flatViewFieldGroupMaps,
updateViewFieldInput,
});
@@ -7,7 +7,7 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
import { ViewFieldController } from 'src/engine/metadata-modules/view-field/controllers/view-field.controller';
import { ViewFieldEntity } from 'src/engine/metadata-modules/view-field/entities/view-field.entity';
import { ViewFieldResolver } from 'src/engine/metadata-modules/view-field/resolvers/view-field.resolver';
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 { ViewPermissionsModule } from 'src/engine/metadata-modules/view-permissions/view-permissions.module';
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
@@ -24,7 +24,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
ViewPermissionsModule,
],
controllers: [ViewFieldController],
providers: [ViewFieldResolver, ViewFieldV2Service],
exports: [ViewFieldV2Service],
providers: [ViewFieldResolver, ViewFieldService],
exports: [ViewFieldService],
})
export class ViewFieldModule {}