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
@@ -7,12 +7,16 @@ import {
type MorphRelationLoaderPayload,
type ObjectMetadataLoaderPayload,
type RelationLoaderPayload,
type ViewFieldGroupsByViewIdLoaderPayload,
type ViewFieldsByViewFieldGroupIdLoaderPayload,
} from 'src/engine/dataloaders/dataloader.service';
import { type FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto';
import { type RelationDTO } from 'src/engine/metadata-modules/field-metadata/dtos/relation.dto';
import { type IndexFieldMetadataDTO } from 'src/engine/metadata-modules/index-metadata/dtos/index-field-metadata.dto';
import { type IndexMetadataDTO } from 'src/engine/metadata-modules/index-metadata/dtos/index-metadata.dto';
import { type ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto';
import { type ViewFieldGroupDTO } from 'src/engine/metadata-modules/view-field-group/dtos/view-field-group.dto';
import { type ViewFieldDTO } from 'src/engine/metadata-modules/view-field/dtos/view-field.dto';
export interface IDataloaders {
relationLoader: DataLoader<RelationLoaderPayload, RelationDTO | null>;
@@ -41,4 +45,14 @@ export interface IDataloaders {
ObjectMetadataLoaderPayload,
ObjectMetadataDTO | null
>;
viewFieldGroupsByViewIdLoader: DataLoader<
ViewFieldGroupsByViewIdLoaderPayload,
ViewFieldGroupDTO[]
>;
viewFieldsByViewFieldGroupIdLoader: DataLoader<
ViewFieldsByViewFieldGroupIdLoaderPayload,
ViewFieldDTO[]
>;
}
@@ -18,6 +18,7 @@ import { resolveFieldMetadataStandardOverride } from 'src/engine/metadata-module
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 { findManyFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-many-flat-entity-by-id-in-flat-entity-maps.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 { findAllOthersMorphRelationFlatFieldMetadatasOrThrow } from 'src/engine/metadata-modules/flat-field-metadata/utils/find-all-others-morph-relation-flat-field-metadatas-or-throw.util';
import { fromFlatFieldMetadataToFieldMetadataDto } from 'src/engine/metadata-modules/flat-field-metadata/utils/from-flat-field-metadata-to-field-metadata-dto.util';
@@ -26,6 +27,8 @@ import { isFlatFieldMetadataOfType } from 'src/engine/metadata-modules/flat-fiel
import { isMorphOrRelationFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-morph-or-relation-flat-field-metadata.util';
import { fromFlatObjectMetadataToObjectMetadataDto } from 'src/engine/metadata-modules/flat-object-metadata/utils/from-flat-object-metadata-to-object-metadata-dto.util';
import { getMorphNameFromMorphFieldMetadataName } from 'src/engine/metadata-modules/flat-object-metadata/utils/get-morph-name-from-morph-field-metadata-name.util';
import { fromFlatViewFieldGroupToViewFieldGroupDto } from 'src/engine/metadata-modules/view-field-group/utils/from-flat-view-field-group-to-view-field-group-dto.util';
import { fromFlatViewFieldToViewFieldDto } from 'src/engine/metadata-modules/view-field/utils/from-flat-view-field-to-view-field-dto.util';
import { type IndexFieldMetadataDTO } from 'src/engine/metadata-modules/index-metadata/dtos/index-field-metadata.dto';
import { type IndexMetadataDTO } from 'src/engine/metadata-modules/index-metadata/dtos/index-metadata.dto';
import { ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto';
@@ -70,6 +73,16 @@ export type ObjectMetadataLoaderPayload = {
objectMetadataId: string;
};
export type ViewFieldGroupsByViewIdLoaderPayload = {
workspaceId: string;
viewId: string;
};
export type ViewFieldsByViewFieldGroupIdLoaderPayload = {
workspaceId: string;
viewFieldGroupId: string;
};
@Injectable()
export class DataloaderService {
constructor(
@@ -84,6 +97,10 @@ export class DataloaderService {
const indexMetadataLoader = this.createIndexMetadataLoader();
const indexFieldMetadataLoader = this.createIndexFieldMetadataLoader();
const objectMetadataLoader = this.createObjectMetadataLoader();
const viewFieldGroupsByViewIdLoader =
this.createViewFieldGroupsByViewIdLoader();
const viewFieldsByViewFieldGroupIdLoader =
this.createViewFieldsByViewFieldGroupIdLoader();
return {
relationLoader,
@@ -92,6 +109,8 @@ export class DataloaderService {
indexMetadataLoader,
indexFieldMetadataLoader,
objectMetadataLoader,
viewFieldGroupsByViewIdLoader,
viewFieldsByViewFieldGroupIdLoader,
};
}
@@ -515,4 +534,74 @@ export class DataloaderService {
});
});
}
private createViewFieldGroupsByViewIdLoader() {
return new DataLoader<
ViewFieldGroupsByViewIdLoaderPayload,
ReturnType<typeof fromFlatViewFieldGroupToViewFieldGroupDto>[]
>(async (dataLoaderParams: ViewFieldGroupsByViewIdLoaderPayload[]) => {
const workspaceId = dataLoaderParams[0].workspaceId;
const { flatViewMaps, flatViewFieldGroupMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatViewMaps', 'flatViewFieldGroupMaps'],
},
);
return dataLoaderParams.map(({ viewId }) => {
const flatView = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: viewId,
flatEntityMaps: flatViewMaps,
});
if (!isDefined(flatView)) {
return [];
}
return findManyFlatEntityByIdInFlatEntityMaps({
flatEntityIds: flatView.viewFieldGroupIds,
flatEntityMaps: flatViewFieldGroupMaps,
})
.filter((flatViewFieldGroup) => flatViewFieldGroup.deletedAt === null)
.map(fromFlatViewFieldGroupToViewFieldGroupDto);
});
});
}
private createViewFieldsByViewFieldGroupIdLoader() {
return new DataLoader<
ViewFieldsByViewFieldGroupIdLoaderPayload,
ReturnType<typeof fromFlatViewFieldToViewFieldDto>[]
>(async (dataLoaderParams: ViewFieldsByViewFieldGroupIdLoaderPayload[]) => {
const workspaceId = dataLoaderParams[0].workspaceId;
const { flatViewFieldGroupMaps, flatViewFieldMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatViewFieldGroupMaps', 'flatViewFieldMaps'],
},
);
return dataLoaderParams.map(({ viewFieldGroupId }) => {
const flatViewFieldGroup = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: viewFieldGroupId,
flatEntityMaps: flatViewFieldGroupMaps,
});
if (!isDefined(flatViewFieldGroup)) {
return [];
}
return findManyFlatEntityByIdInFlatEntityMaps({
flatEntityIds: flatViewFieldGroup.viewFieldIds,
flatEntityMaps: flatViewFieldMaps,
})
.filter((flatViewField) => flatViewField.deletedAt === null)
.map(fromFlatViewFieldToViewFieldDto);
});
});
}
}