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
@@ -5,4 +5,5 @@ export const FLAT_VIEW_FIELD_EDITABLE_PROPERTIES = [
'size',
'position',
'aggregateOperation',
'viewFieldGroupId',
] as const satisfies MetadataEntityPropertyName<'viewField'>[];
@@ -10,6 +10,7 @@ import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/
import { createEmptyFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-flat-entity-maps.constant';
import { FlatViewFieldMaps } from 'src/engine/metadata-modules/flat-view-field/types/flat-view-field-maps.type';
import { fromViewFieldEntityToFlatViewField } from 'src/engine/metadata-modules/flat-view-field/utils/from-view-field-entity-to-flat-view-field.util';
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 { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
import { WorkspaceCache } from 'src/engine/workspace-cache/decorators/workspace-cache.decorator';
@@ -28,13 +29,15 @@ export class WorkspaceFlatViewFieldMapCacheService extends WorkspaceCacheProvide
private readonly fieldMetadataRepository: Repository<FieldMetadataEntity>,
@InjectRepository(ViewEntity)
private readonly viewRepository: Repository<ViewEntity>,
@InjectRepository(ViewFieldGroupEntity)
private readonly viewFieldGroupRepository: Repository<ViewFieldGroupEntity>,
) {
super();
}
async computeForCache(workspaceId: string): Promise<FlatViewFieldMaps> {
const [viewFields, applications, fieldMetadatas, views] = await Promise.all(
[
const [viewFields, applications, fieldMetadatas, views, viewFieldGroups] =
await Promise.all([
this.viewFieldRepository.find({
where: { workspaceId },
withDeleted: true,
@@ -54,8 +57,12 @@ export class WorkspaceFlatViewFieldMapCacheService extends WorkspaceCacheProvide
select: ['id', 'universalIdentifier'],
withDeleted: true,
}),
],
);
this.viewFieldGroupRepository.find({
where: { workspaceId },
select: ['id', 'universalIdentifier'],
withDeleted: true,
}),
]);
const applicationIdToUniversalIdentifierMap =
createIdToUniversalIdentifierMap(applications);
@@ -63,6 +70,8 @@ export class WorkspaceFlatViewFieldMapCacheService extends WorkspaceCacheProvide
createIdToUniversalIdentifierMap(fieldMetadatas);
const viewIdToUniversalIdentifierMap =
createIdToUniversalIdentifierMap(views);
const viewFieldGroupIdToUniversalIdentifierMap =
createIdToUniversalIdentifierMap(viewFieldGroups);
const flatViewFieldMaps = createEmptyFlatEntityMaps();
@@ -72,6 +81,7 @@ export class WorkspaceFlatViewFieldMapCacheService extends WorkspaceCacheProvide
applicationIdToUniversalIdentifierMap,
fieldMetadataIdToUniversalIdentifierMap,
viewIdToUniversalIdentifierMap,
viewFieldGroupIdToUniversalIdentifierMap,
});
addFlatEntityToFlatEntityMapsThroughMutationOrThrow({
@@ -11,31 +11,48 @@ import { type UniversalFlatViewField } from 'src/engine/workspace-manager/worksp
export type FromCreateViewFieldInputToFlatViewFieldToCreateArgs = {
createViewFieldInput: CreateViewFieldInput;
flatApplication: FlatApplication;
} & Pick<AllFlatEntityMaps, 'flatFieldMetadataMaps' | 'flatViewMaps'>;
} & Pick<
AllFlatEntityMaps,
'flatFieldMetadataMaps' | 'flatViewMaps' | 'flatViewFieldGroupMaps'
>;
export const fromCreateViewFieldInputToFlatViewFieldToCreate = ({
createViewFieldInput: rawCreateViewFieldInput,
flatApplication,
flatFieldMetadataMaps,
flatViewMaps,
flatViewFieldGroupMaps,
}: FromCreateViewFieldInputToFlatViewFieldToCreateArgs): UniversalFlatViewField & {
id: string;
} => {
const { fieldMetadataId, viewId, ...createViewFieldInput } =
const { fieldMetadataId, viewId, viewFieldGroupId, ...createViewFieldInput } =
trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties(
rawCreateViewFieldInput,
['aggregateOperation', 'fieldMetadataId', 'id', 'viewId'],
[
'aggregateOperation',
'fieldMetadataId',
'id',
'viewId',
'viewFieldGroupId',
],
);
const createdAt = new Date().toISOString();
const viewFieldId = createViewFieldInput.id ?? v4();
const { fieldMetadataUniversalIdentifier, viewUniversalIdentifier } =
resolveEntityRelationUniversalIdentifiers({
metadataName: 'viewField',
foreignKeyValues: { fieldMetadataId, viewId },
flatEntityMaps: { flatFieldMetadataMaps, flatViewMaps },
});
const {
fieldMetadataUniversalIdentifier,
viewUniversalIdentifier,
viewFieldGroupUniversalIdentifier,
} = resolveEntityRelationUniversalIdentifiers({
metadataName: 'viewField',
foreignKeyValues: { fieldMetadataId, viewId, viewFieldGroupId },
flatEntityMaps: {
flatFieldMetadataMaps,
flatViewMaps,
flatViewFieldGroupMaps,
},
});
return {
id: viewFieldId,
@@ -49,6 +66,7 @@ export const fromCreateViewFieldInputToFlatViewFieldToCreate = ({
size: createViewFieldInput.size ?? DEFAULT_VIEW_FIELD_SIZE,
position: createViewFieldInput.position ?? 0,
aggregateOperation: createViewFieldInput.aggregateOperation ?? null,
viewFieldGroupUniversalIdentifier,
applicationUniversalIdentifier: flatApplication.universalIdentifier,
};
};
@@ -5,8 +5,10 @@ import {
trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties,
} from 'twenty-shared/utils';
import { FLAT_VIEW_FIELD_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-view-field/constants/flat-view-field-editable-properties.constant';
import { type AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { resolveEntityRelationUniversalIdentifiers } from 'src/engine/metadata-modules/flat-entity/utils/resolve-entity-relation-universal-identifiers.util';
import { FLAT_VIEW_FIELD_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-view-field/constants/flat-view-field-editable-properties.constant';
import { type FlatViewFieldMaps } from 'src/engine/metadata-modules/flat-view-field/types/flat-view-field-maps.type';
import { type UpdateViewFieldInput } from 'src/engine/metadata-modules/view-field/dtos/inputs/update-view-field.input';
import {
@@ -19,10 +21,14 @@ import { mergeUpdateInExistingRecord } from 'src/utils/merge-update-in-existing-
export const fromUpdateViewFieldInputToFlatViewFieldToUpdateOrThrow = ({
updateViewFieldInput: rawUpdateViewFieldInput,
flatViewFieldMaps,
flatViewFieldGroupMaps,
}: {
updateViewFieldInput: UpdateViewFieldInput;
flatViewFieldMaps: FlatViewFieldMaps;
}): UniversalFlatViewField => {
} & Pick<
AllFlatEntityMaps,
'flatViewFieldGroupMaps'
>): UniversalFlatViewField => {
const { id: viewFieldToUpdateId } =
trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties(
rawUpdateViewFieldInput,
@@ -40,14 +46,31 @@ export const fromUpdateViewFieldInputToFlatViewFieldToUpdateOrThrow = ({
ViewFieldExceptionCode.VIEW_FIELD_NOT_FOUND,
);
}
const updatedEditableFieldProperties = extractAndSanitizeObjectStringFields(
rawUpdateViewFieldInput.update,
FLAT_VIEW_FIELD_EDITABLE_PROPERTIES,
);
return mergeUpdateInExistingRecord({
const flatViewFieldToUpdate = mergeUpdateInExistingRecord({
existing: existingFlatViewFieldToUpdate,
properties: FLAT_VIEW_FIELD_EDITABLE_PROPERTIES,
update: updatedEditableFieldProperties,
});
if (updatedEditableFieldProperties.viewFieldGroupId !== undefined) {
const { viewFieldGroupUniversalIdentifier } =
resolveEntityRelationUniversalIdentifiers({
metadataName: 'viewField',
foreignKeyValues: {
viewFieldGroupId: flatViewFieldToUpdate.viewFieldGroupId,
},
flatEntityMaps: { flatViewFieldGroupMaps },
});
flatViewFieldToUpdate.viewFieldGroupUniversalIdentifier =
viewFieldGroupUniversalIdentifier;
}
return flatViewFieldToUpdate;
};
@@ -13,6 +13,7 @@ export const fromViewFieldEntityToFlatViewField = ({
applicationIdToUniversalIdentifierMap,
fieldMetadataIdToUniversalIdentifierMap,
viewIdToUniversalIdentifierMap,
viewFieldGroupIdToUniversalIdentifierMap,
}: FromEntityToFlatEntityArgs<'viewField'>): FlatViewField => {
const viewFieldEntityWithoutRelations = removePropertiesFromRecord(
viewFieldEntity,
@@ -52,6 +53,22 @@ export const fromViewFieldEntityToFlatViewField = ({
);
}
let viewFieldGroupUniversalIdentifier: string | null = null;
if (isDefined(viewFieldEntity.viewFieldGroupId)) {
viewFieldGroupUniversalIdentifier =
viewFieldGroupIdToUniversalIdentifierMap.get(
viewFieldEntity.viewFieldGroupId,
) ?? null;
if (!isDefined(viewFieldGroupUniversalIdentifier)) {
throw new FlatEntityMapsException(
`ViewFieldGroup with id ${viewFieldEntity.viewFieldGroupId} not found for viewField ${viewFieldEntity.id}`,
FlatEntityMapsExceptionCode.ENTITY_NOT_FOUND,
);
}
}
return {
...viewFieldEntityWithoutRelations,
createdAt: viewFieldEntity.createdAt.toISOString(),
@@ -61,5 +78,6 @@ export const fromViewFieldEntityToFlatViewField = ({
applicationUniversalIdentifier,
fieldMetadataUniversalIdentifier,
viewUniversalIdentifier,
viewFieldGroupUniversalIdentifier,
};
};