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
@@ -0,0 +1,8 @@
import { type MetadataEntityPropertyName } from 'src/engine/metadata-modules/flat-entity/constant/all-entity-properties-configuration-by-metadata-name.constant';
export const FLAT_VIEW_FIELD_GROUP_EDITABLE_PROPERTIES = [
'name',
'position',
'isVisible',
'deletedAt',
] as const satisfies MetadataEntityPropertyName<'viewFieldGroup'>[];
@@ -0,0 +1,92 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { WorkspaceCacheProvider } from 'src/engine/workspace-cache/interfaces/workspace-cache-provider.service';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { createEmptyFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-flat-entity-maps.constant';
import { FlatViewFieldGroupMaps } from 'src/engine/metadata-modules/flat-view-field-group/types/flat-view-field-group-maps.type';
import { fromViewFieldGroupEntityToFlatViewFieldGroup } from 'src/engine/metadata-modules/flat-view-field-group/utils/from-view-field-group-entity-to-flat-view-field-group.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';
import { createIdToUniversalIdentifierMap } from 'src/engine/workspace-cache/utils/create-id-to-universal-identifier-map.util';
import { regroupEntitiesByRelatedEntityId } from 'src/engine/workspace-cache/utils/regroup-entities-by-related-entity-id';
import { addFlatEntityToFlatEntityMapsThroughMutationOrThrow } from 'src/engine/workspace-manager/workspace-migration/utils/add-flat-entity-to-flat-entity-maps-through-mutation-or-throw.util';
@Injectable()
@WorkspaceCache('flatViewFieldGroupMaps')
export class WorkspaceFlatViewFieldGroupMapCacheService extends WorkspaceCacheProvider<FlatViewFieldGroupMaps> {
constructor(
@InjectRepository(ViewFieldGroupEntity)
private readonly viewFieldGroupRepository: Repository<ViewFieldGroupEntity>,
@InjectRepository(ApplicationEntity)
private readonly applicationRepository: Repository<ApplicationEntity>,
@InjectRepository(ViewEntity)
private readonly viewRepository: Repository<ViewEntity>,
@InjectRepository(ViewFieldEntity)
private readonly viewFieldRepository: Repository<ViewFieldEntity>,
) {
super();
}
async computeForCache(workspaceId: string): Promise<FlatViewFieldGroupMaps> {
const [viewFieldGroups, applications, views, viewFields] =
await Promise.all([
this.viewFieldGroupRepository.find({
where: { workspaceId },
withDeleted: true,
}),
this.applicationRepository.find({
where: { workspaceId },
select: ['id', 'universalIdentifier'],
withDeleted: true,
}),
this.viewRepository.find({
where: { workspaceId },
select: ['id', 'universalIdentifier'],
withDeleted: true,
}),
this.viewFieldRepository.find({
where: { workspaceId },
select: ['id', 'universalIdentifier', 'viewFieldGroupId'],
withDeleted: true,
}),
]);
const applicationIdToUniversalIdentifierMap =
createIdToUniversalIdentifierMap(applications);
const viewIdToUniversalIdentifierMap =
createIdToUniversalIdentifierMap(views);
const viewFieldsByViewFieldGroupId =
regroupEntitiesByRelatedEntityId<'viewField'>({
entities: viewFields,
foreignKey: 'viewFieldGroupId',
});
const flatViewFieldGroupMaps = createEmptyFlatEntityMaps();
for (const viewFieldGroupEntity of viewFieldGroups) {
const flatViewFieldGroup = fromViewFieldGroupEntityToFlatViewFieldGroup({
entity: {
...viewFieldGroupEntity,
viewFields:
viewFieldsByViewFieldGroupId.get(viewFieldGroupEntity.id) || [],
},
applicationIdToUniversalIdentifierMap,
viewIdToUniversalIdentifierMap,
});
addFlatEntityToFlatEntityMapsThroughMutationOrThrow({
flatEntity: flatViewFieldGroup,
flatEntityMapsToMutate: flatViewFieldGroupMaps,
});
}
return flatViewFieldGroupMaps;
}
}
@@ -0,0 +1,4 @@
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { type FlatViewFieldGroup } from 'src/engine/metadata-modules/flat-view-field-group/types/flat-view-field-group.type';
export type FlatViewFieldGroupMaps = FlatEntityMaps<FlatViewFieldGroup>;
@@ -0,0 +1,4 @@
import { type FlatEntityFrom } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-from.type';
import { type ViewFieldGroupEntity } from 'src/engine/metadata-modules/view-field-group/entities/view-field-group.entity';
export type FlatViewFieldGroup = FlatEntityFrom<ViewFieldGroupEntity>;
@@ -0,0 +1,52 @@
import { trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
import { type AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type';
import { resolveEntityRelationUniversalIdentifiers } from 'src/engine/metadata-modules/flat-entity/utils/resolve-entity-relation-universal-identifiers.util';
import { type CreateViewFieldGroupInput } from 'src/engine/metadata-modules/view-field-group/dtos/inputs/create-view-field-group.input';
import { type UniversalFlatViewFieldGroup } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-view-field-group.type';
export type FromCreateViewFieldGroupInputToFlatViewFieldGroupToCreateArgs = {
createViewFieldGroupInput: CreateViewFieldGroupInput;
flatApplication: FlatApplication;
} & Pick<AllFlatEntityMaps, 'flatViewMaps'>;
export const fromCreateViewFieldGroupInputToFlatViewFieldGroupToCreate = ({
createViewFieldGroupInput: rawCreateViewFieldGroupInput,
flatApplication,
flatViewMaps,
}: FromCreateViewFieldGroupInputToFlatViewFieldGroupToCreateArgs): UniversalFlatViewFieldGroup & {
id: string;
} => {
const { viewId, ...createViewFieldGroupInput } =
trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties(
rawCreateViewFieldGroupInput,
['id', 'name', 'viewId'],
);
const createdAt = new Date().toISOString();
const viewFieldGroupId = createViewFieldGroupInput.id ?? v4();
const { viewUniversalIdentifier } = resolveEntityRelationUniversalIdentifiers(
{
metadataName: 'viewFieldGroup',
foreignKeyValues: { viewId },
flatEntityMaps: { flatViewMaps },
},
);
return {
id: viewFieldGroupId,
name: createViewFieldGroupInput.name,
viewUniversalIdentifier,
createdAt,
updatedAt: createdAt,
deletedAt: null,
universalIdentifier: createViewFieldGroupInput.universalIdentifier ?? v4(),
position: createViewFieldGroupInput.position ?? 0,
isVisible: createViewFieldGroupInput.isVisible ?? true,
viewFieldUniversalIdentifiers: [],
applicationUniversalIdentifier: flatApplication.universalIdentifier,
};
};
@@ -0,0 +1,46 @@
import { t } from '@lingui/core/macro';
import {
extractAndSanitizeObjectStringFields,
isDefined,
} from 'twenty-shared/utils';
import { type FlatViewFieldGroupMaps } from 'src/engine/metadata-modules/flat-view-field-group/types/flat-view-field-group-maps.type';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { type DeleteViewFieldGroupInput } from 'src/engine/metadata-modules/view-field-group/dtos/inputs/delete-view-field-group.input';
import {
ViewFieldGroupException,
ViewFieldGroupExceptionCode,
} from 'src/engine/metadata-modules/view-field-group/exceptions/view-field-group.exception';
import { type UniversalFlatViewFieldGroup } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-view-field-group.type';
export const fromDeleteViewFieldGroupInputToFlatViewFieldGroupOrThrow = ({
deleteViewFieldGroupInput: rawDeleteViewFieldGroupInput,
flatViewFieldGroupMaps,
}: {
deleteViewFieldGroupInput: DeleteViewFieldGroupInput;
flatViewFieldGroupMaps: FlatViewFieldGroupMaps;
}): UniversalFlatViewFieldGroup => {
const { id: viewFieldGroupId } = extractAndSanitizeObjectStringFields(
rawDeleteViewFieldGroupInput,
['id'],
);
const existingFlatViewFieldGroupToDelete = findFlatEntityByIdInFlatEntityMaps(
{
flatEntityId: viewFieldGroupId,
flatEntityMaps: flatViewFieldGroupMaps,
},
);
if (!isDefined(existingFlatViewFieldGroupToDelete)) {
throw new ViewFieldGroupException(
t`View field group to delete not found`,
ViewFieldGroupExceptionCode.VIEW_FIELD_GROUP_NOT_FOUND,
);
}
return {
...existingFlatViewFieldGroupToDelete,
deletedAt: new Date().toISOString(),
};
};
@@ -0,0 +1,42 @@
import { t } from '@lingui/core/macro';
import {
extractAndSanitizeObjectStringFields,
isDefined,
} from 'twenty-shared/utils';
import { type FlatViewFieldGroupMaps } from 'src/engine/metadata-modules/flat-view-field-group/types/flat-view-field-group-maps.type';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { type DestroyViewFieldGroupInput } from 'src/engine/metadata-modules/view-field-group/dtos/inputs/destroy-view-field-group.input';
import {
ViewFieldGroupException,
ViewFieldGroupExceptionCode,
} from 'src/engine/metadata-modules/view-field-group/exceptions/view-field-group.exception';
import { type UniversalFlatViewFieldGroup } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-view-field-group.type';
export const fromDestroyViewFieldGroupInputToFlatViewFieldGroupOrThrow = ({
destroyViewFieldGroupInput,
flatViewFieldGroupMaps,
}: {
destroyViewFieldGroupInput: DestroyViewFieldGroupInput;
flatViewFieldGroupMaps: FlatViewFieldGroupMaps;
}): UniversalFlatViewFieldGroup => {
const { id: viewFieldGroupId } = extractAndSanitizeObjectStringFields(
destroyViewFieldGroupInput,
['id'],
);
const existingFlatViewFieldGroupToDestroy =
findFlatEntityByIdInFlatEntityMaps({
flatEntityId: viewFieldGroupId,
flatEntityMaps: flatViewFieldGroupMaps,
});
if (!isDefined(existingFlatViewFieldGroupToDestroy)) {
throw new ViewFieldGroupException(
t`View field group to destroy not found`,
ViewFieldGroupExceptionCode.VIEW_FIELD_GROUP_NOT_FOUND,
);
}
return existingFlatViewFieldGroupToDestroy;
};
@@ -0,0 +1,56 @@
import { t } from '@lingui/core/macro';
import {
extractAndSanitizeObjectStringFields,
isDefined,
trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties,
} from 'twenty-shared/utils';
import { FLAT_VIEW_FIELD_GROUP_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-view-field-group/constants/flat-view-field-group-editable-properties.constant';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { type FlatViewFieldGroupMaps } from 'src/engine/metadata-modules/flat-view-field-group/types/flat-view-field-group-maps.type';
import { type UpdateViewFieldGroupInput } from 'src/engine/metadata-modules/view-field-group/dtos/inputs/update-view-field-group.input';
import {
ViewFieldGroupException,
ViewFieldGroupExceptionCode,
} from 'src/engine/metadata-modules/view-field-group/exceptions/view-field-group.exception';
import { type UniversalFlatViewFieldGroup } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-view-field-group.type';
import { mergeUpdateInExistingRecord } from 'src/utils/merge-update-in-existing-record.util';
export const fromUpdateViewFieldGroupInputToFlatViewFieldGroupToUpdateOrThrow =
({
updateViewFieldGroupInput: rawUpdateViewFieldGroupInput,
flatViewFieldGroupMaps,
}: {
updateViewFieldGroupInput: UpdateViewFieldGroupInput;
flatViewFieldGroupMaps: FlatViewFieldGroupMaps;
}): UniversalFlatViewFieldGroup => {
const { id: viewFieldGroupToUpdateId } =
trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties(
rawUpdateViewFieldGroupInput,
['id'],
);
const existingFlatViewFieldGroupToUpdate =
findFlatEntityByIdInFlatEntityMaps({
flatEntityId: viewFieldGroupToUpdateId,
flatEntityMaps: flatViewFieldGroupMaps,
});
if (!isDefined(existingFlatViewFieldGroupToUpdate)) {
throw new ViewFieldGroupException(
t`View field group to update not found`,
ViewFieldGroupExceptionCode.VIEW_FIELD_GROUP_NOT_FOUND,
);
}
const updatedEditableProperties = extractAndSanitizeObjectStringFields(
rawUpdateViewFieldGroupInput.update,
FLAT_VIEW_FIELD_GROUP_EDITABLE_PROPERTIES,
);
return mergeUpdateInExistingRecord({
existing: existingFlatViewFieldGroupToUpdate,
properties: FLAT_VIEW_FIELD_GROUP_EDITABLE_PROPERTIES,
update: updatedEditableProperties,
});
};
@@ -0,0 +1,60 @@
import { isDefined, removePropertiesFromRecord } from 'twenty-shared/utils';
import {
FlatEntityMapsException,
FlatEntityMapsExceptionCode,
} from 'src/engine/metadata-modules/flat-entity/exceptions/flat-entity-maps.exception';
import { getMetadataEntityRelationProperties } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-entity-relation-properties.util';
import { type FlatViewFieldGroup } from 'src/engine/metadata-modules/flat-view-field-group/types/flat-view-field-group.type';
import { type FromEntityToFlatEntityArgs } from 'src/engine/workspace-cache/types/from-entity-to-flat-entity-args.type';
export const fromViewFieldGroupEntityToFlatViewFieldGroup = ({
entity: viewFieldGroupEntity,
applicationIdToUniversalIdentifierMap,
viewIdToUniversalIdentifierMap,
}: FromEntityToFlatEntityArgs<'viewFieldGroup'>): FlatViewFieldGroup => {
const viewFieldGroupEntityWithoutRelations = removePropertiesFromRecord(
viewFieldGroupEntity,
getMetadataEntityRelationProperties('viewFieldGroup'),
);
const applicationUniversalIdentifier =
applicationIdToUniversalIdentifierMap.get(
viewFieldGroupEntity.applicationId,
);
if (!isDefined(applicationUniversalIdentifier)) {
throw new FlatEntityMapsException(
`Application with id ${viewFieldGroupEntity.applicationId} not found for viewFieldGroup ${viewFieldGroupEntity.id}`,
FlatEntityMapsExceptionCode.ENTITY_NOT_FOUND,
);
}
const viewUniversalIdentifier = viewIdToUniversalIdentifierMap.get(
viewFieldGroupEntity.viewId,
);
if (!isDefined(viewUniversalIdentifier)) {
throw new FlatEntityMapsException(
`View with id ${viewFieldGroupEntity.viewId} not found for viewFieldGroup ${viewFieldGroupEntity.id}`,
FlatEntityMapsExceptionCode.ENTITY_NOT_FOUND,
);
}
return {
...viewFieldGroupEntityWithoutRelations,
createdAt: viewFieldGroupEntity.createdAt.toISOString(),
updatedAt: viewFieldGroupEntity.updatedAt.toISOString(),
deletedAt: viewFieldGroupEntity.deletedAt?.toISOString() ?? null,
universalIdentifier:
viewFieldGroupEntityWithoutRelations.universalIdentifier,
applicationUniversalIdentifier,
viewUniversalIdentifier,
viewFieldIds:
viewFieldGroupEntity.viewFields?.map((viewField) => viewField.id) ?? [],
viewFieldUniversalIdentifiers:
viewFieldGroupEntity.viewFields?.map(
(viewField) => viewField.universalIdentifier,
) ?? [],
};
};