Allow users to create Fields and Field widget (#18801)

- Allow users to create a Fields widget or a Field widget; **this PR is
focused on Fields widgets as Field widget can't be configured yet**
- Automatically create a filled view when the user creates a draft
Fields widget in edit mode


https://github.com/user-attachments/assets/b2dbba52-c614-44cd-bf6c-095ce9d4ec26

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Baptiste Devessier
2026-03-23 00:44:49 +01:00
committed by GitHub
parent dc00701448
commit e9aa6f47e5
19 changed files with 1921 additions and 771 deletions
@@ -10,8 +10,8 @@ import { ApplicationService } from 'src/engine/core-modules/application/applicat
import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
import { generateMessageId } from 'src/engine/core-modules/i18n/utils/generateMessageId';
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 { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { findFlatEntityByUniversalIdentifierOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier-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 { type FlatView } from 'src/engine/metadata-modules/flat-view/types/flat-view.type';
@@ -31,9 +31,12 @@ import { DestroyViewInput } from 'src/engine/metadata-modules/view/dtos/inputs/d
import { UpdateViewInput } from 'src/engine/metadata-modules/view/dtos/inputs/update-view.input';
import { ViewDTO } from 'src/engine/metadata-modules/view/dtos/view.dto';
import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity';
import { computeFieldsWidgetViewFieldsAndGroupsToCreate } from 'src/engine/metadata-modules/view/utils/compute-fields-widget-view-fields-and-groups-to-create.util';
import { fromFlatViewToViewDto } from 'src/engine/metadata-modules/view/utils/from-flat-view-to-view-dto.util';
import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
import { type UniversalFlatViewFieldGroup } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-view-field-group.type';
import { type UniversalFlatViewField } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-view-field.type';
@Injectable()
export class ViewService {
@@ -82,6 +85,39 @@ export class ViewService {
flatObjectMetadataMaps: existingFlatObjectMetadataMaps,
});
let flatViewFieldGroupsToCreate: UniversalFlatViewFieldGroup[] = [];
let flatViewFieldsToCreate: UniversalFlatViewField[] = [];
if (flatViewToCreate.type === ViewType.FIELDS_WIDGET) {
const objectFlatFieldMetadatas = Object.values(
existingFlatFieldMetadataMaps.byUniversalIdentifier,
).filter(
(field): field is NonNullable<typeof field> =>
field !== undefined &&
field.objectMetadataUniversalIdentifier ===
flatViewToCreate.objectMetadataUniversalIdentifier,
);
const objectFlatMetadata = findFlatEntityByUniversalIdentifierOrThrow({
flatEntityMaps: existingFlatObjectMetadataMaps,
universalIdentifier: flatViewToCreate.objectMetadataUniversalIdentifier,
});
const fieldsWidgetResult = computeFieldsWidgetViewFieldsAndGroupsToCreate(
{
objectFlatFieldMetadatas,
viewUniversalIdentifier: flatViewToCreate.universalIdentifier,
flatApplication: workspaceCustomFlatApplication,
labelIdentifierFieldMetadataUniversalIdentifier:
objectFlatMetadata.labelIdentifierFieldMetadataUniversalIdentifier,
},
);
flatViewFieldGroupsToCreate =
fieldsWidgetResult.flatViewFieldGroupsToCreate;
flatViewFieldsToCreate = fieldsWidgetResult.flatViewFieldsToCreate;
}
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
@@ -97,6 +133,18 @@ export class ViewService {
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
viewFieldGroup: {
flatEntityToCreate: flatViewFieldGroupsToCreate,
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
viewField: {
flatEntityToCreate: flatViewFieldsToCreate,
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
},
workspaceId,
isSystemBuild: false,
@@ -0,0 +1,415 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { v4 } from 'uuid';
import { DEFAULT_VIEW_FIELD_SIZE } from 'src/engine/metadata-modules/flat-view-field/constants/default-view-field-size.constant';
import { type UniversalFlatFieldMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-field-metadata.type';
import { computeFieldsWidgetViewFieldsAndGroupsToCreate } from '../compute-fields-widget-view-fields-and-groups-to-create.util';
const makeFieldMetadata = (
overrides: Partial<UniversalFlatFieldMetadata> & {
name: string;
type: FieldMetadataType;
},
): UniversalFlatFieldMetadata => {
const universalIdentifier = overrides.universalIdentifier ?? v4();
return {
universalIdentifier,
objectMetadataUniversalIdentifier: 'object-uid',
applicationUniversalIdentifier: 'app-uid',
name: overrides.name,
label: overrides.label ?? overrides.name,
type: overrides.type,
isCustom: overrides.isCustom ?? false,
isActive: true,
isSystem: false,
isUIReadOnly: false,
isNullable: true,
isUnique: false,
isLabelSyncedWithName: false,
defaultValue: null,
description: null,
icon: null,
options: null,
settings: null,
standardOverrides: null,
relationTargetObjectMetadataUniversalIdentifier: null,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
deletedAt: null,
} as unknown as UniversalFlatFieldMetadata;
};
const flatApplication = {
universalIdentifier: 'app-uid',
id: 'app-id',
} as never;
const viewUniversalIdentifier = 'view-uid';
describe('computeFieldsWidgetViewFieldsAndGroupsToCreate', () => {
it('should create a "General" group with standard fields', () => {
const fields = [
makeFieldMetadata({
name: 'name',
type: FieldMetadataType.TEXT,
isCustom: false,
}),
makeFieldMetadata({
name: 'createdAt',
type: FieldMetadataType.DATE_TIME,
isCustom: false,
}),
];
const result = computeFieldsWidgetViewFieldsAndGroupsToCreate({
objectFlatFieldMetadatas: fields,
viewUniversalIdentifier,
flatApplication,
labelIdentifierFieldMetadataUniversalIdentifier: null,
});
expect(result.flatViewFieldGroupsToCreate).toHaveLength(1);
expect(result.flatViewFieldGroupsToCreate[0].name).toBe('General');
expect(result.flatViewFieldGroupsToCreate[0].position).toBe(0);
expect(result.flatViewFieldGroupsToCreate[0].isVisible).toBe(true);
expect(result.flatViewFieldsToCreate).toHaveLength(2);
result.flatViewFieldsToCreate.forEach((vf) => {
expect(vf.viewFieldGroupUniversalIdentifier).toBe(
result.flatViewFieldGroupsToCreate[0].universalIdentifier,
);
expect(vf.isVisible).toBe(true);
expect(vf.size).toBe(DEFAULT_VIEW_FIELD_SIZE);
expect(vf.viewUniversalIdentifier).toBe(viewUniversalIdentifier);
});
expect(result.flatViewFieldsToCreate[0].position).toBe(0);
expect(result.flatViewFieldsToCreate[1].position).toBe(1);
});
it('should create an "Other" group when custom fields exist', () => {
const fields = [
makeFieldMetadata({
name: 'name',
type: FieldMetadataType.TEXT,
isCustom: false,
}),
makeFieldMetadata({
name: 'customRating',
type: FieldMetadataType.NUMBER,
isCustom: true,
}),
makeFieldMetadata({
name: 'customTag',
type: FieldMetadataType.TEXT,
isCustom: true,
}),
];
const result = computeFieldsWidgetViewFieldsAndGroupsToCreate({
objectFlatFieldMetadatas: fields,
viewUniversalIdentifier,
flatApplication,
labelIdentifierFieldMetadataUniversalIdentifier: null,
});
expect(result.flatViewFieldGroupsToCreate).toHaveLength(2);
expect(result.flatViewFieldGroupsToCreate[0].name).toBe('General');
expect(result.flatViewFieldGroupsToCreate[0].position).toBe(0);
expect(result.flatViewFieldGroupsToCreate[1].name).toBe('Other');
expect(result.flatViewFieldGroupsToCreate[1].position).toBe(1);
const generalGroupUid =
result.flatViewFieldGroupsToCreate[0].universalIdentifier;
const otherGroupUid =
result.flatViewFieldGroupsToCreate[1].universalIdentifier;
const generalFields = result.flatViewFieldsToCreate.filter(
(vf) => vf.viewFieldGroupUniversalIdentifier === generalGroupUid,
);
const otherFields = result.flatViewFieldsToCreate.filter(
(vf) => vf.viewFieldGroupUniversalIdentifier === otherGroupUid,
);
expect(generalFields).toHaveLength(1);
expect(otherFields).toHaveLength(2);
// Positions are sequential within each group
expect(otherFields[0].position).toBe(0);
expect(otherFields[1].position).toBe(1);
});
it('should not create an "Other" group when there are no custom fields', () => {
const fields = [
makeFieldMetadata({
name: 'name',
type: FieldMetadataType.TEXT,
isCustom: false,
}),
];
const result = computeFieldsWidgetViewFieldsAndGroupsToCreate({
objectFlatFieldMetadatas: fields,
viewUniversalIdentifier,
flatApplication,
labelIdentifierFieldMetadataUniversalIdentifier: null,
});
expect(result.flatViewFieldGroupsToCreate).toHaveLength(1);
expect(result.flatViewFieldGroupsToCreate[0].name).toBe('General');
});
it('should exclude deletedAt field', () => {
const fields = [
makeFieldMetadata({
name: 'name',
type: FieldMetadataType.TEXT,
isCustom: false,
}),
makeFieldMetadata({
name: 'deletedAt',
type: FieldMetadataType.DATE_TIME,
isCustom: false,
}),
];
const result = computeFieldsWidgetViewFieldsAndGroupsToCreate({
objectFlatFieldMetadatas: fields,
viewUniversalIdentifier,
flatApplication,
labelIdentifierFieldMetadataUniversalIdentifier: null,
});
expect(result.flatViewFieldsToCreate).toHaveLength(1);
expect(
result.flatViewFieldsToCreate[0].fieldMetadataUniversalIdentifier,
).toBe(fields[0].universalIdentifier);
});
it('should exclude TS_VECTOR and POSITION fields', () => {
const fields = [
makeFieldMetadata({
name: 'name',
type: FieldMetadataType.TEXT,
isCustom: false,
}),
makeFieldMetadata({
name: 'searchVector',
type: FieldMetadataType.TS_VECTOR,
isCustom: false,
}),
makeFieldMetadata({
name: 'position',
type: FieldMetadataType.POSITION,
isCustom: false,
}),
];
const result = computeFieldsWidgetViewFieldsAndGroupsToCreate({
objectFlatFieldMetadatas: fields,
viewUniversalIdentifier,
flatApplication,
labelIdentifierFieldMetadataUniversalIdentifier: null,
});
expect(result.flatViewFieldsToCreate).toHaveLength(1);
});
it('should exclude id field unless it is the label identifier', () => {
const idUid = v4();
const fields = [
makeFieldMetadata({
name: 'id',
type: FieldMetadataType.UUID,
isCustom: false,
universalIdentifier: idUid,
}),
makeFieldMetadata({
name: 'name',
type: FieldMetadataType.TEXT,
isCustom: false,
}),
];
// Without label identifier match: id excluded
const resultWithout = computeFieldsWidgetViewFieldsAndGroupsToCreate({
objectFlatFieldMetadatas: fields,
viewUniversalIdentifier,
flatApplication,
labelIdentifierFieldMetadataUniversalIdentifier: null,
});
expect(resultWithout.flatViewFieldsToCreate).toHaveLength(1);
// With label identifier match: id included
const resultWith = computeFieldsWidgetViewFieldsAndGroupsToCreate({
objectFlatFieldMetadatas: fields,
viewUniversalIdentifier,
flatApplication,
labelIdentifierFieldMetadataUniversalIdentifier: idUid,
});
expect(resultWith.flatViewFieldsToCreate).toHaveLength(2);
});
it('should set correct applicationUniversalIdentifier on all entities', () => {
const fields = [
makeFieldMetadata({
name: 'name',
type: FieldMetadataType.TEXT,
isCustom: false,
}),
makeFieldMetadata({
name: 'custom',
type: FieldMetadataType.TEXT,
isCustom: true,
}),
];
const result = computeFieldsWidgetViewFieldsAndGroupsToCreate({
objectFlatFieldMetadatas: fields,
viewUniversalIdentifier,
flatApplication,
labelIdentifierFieldMetadataUniversalIdentifier: null,
});
result.flatViewFieldGroupsToCreate.forEach((group) => {
expect(group.applicationUniversalIdentifier).toBe('app-uid');
});
result.flatViewFieldsToCreate.forEach((field) => {
expect(field.applicationUniversalIdentifier).toBe('app-uid');
});
});
it('should return empty fields when all fields are excluded', () => {
const fields = [
makeFieldMetadata({
name: 'deletedAt',
type: FieldMetadataType.DATE_TIME,
isCustom: false,
}),
makeFieldMetadata({
name: 'searchVector',
type: FieldMetadataType.TS_VECTOR,
isCustom: false,
}),
];
const result = computeFieldsWidgetViewFieldsAndGroupsToCreate({
objectFlatFieldMetadatas: fields,
viewUniversalIdentifier,
flatApplication,
labelIdentifierFieldMetadataUniversalIdentifier: null,
});
// Still creates the "General" group, just with no fields
expect(result.flatViewFieldGroupsToCreate).toHaveLength(1);
expect(result.flatViewFieldsToCreate).toHaveLength(0);
});
it('should place label identifier field at position 0', () => {
const labelUid = v4();
const fields = [
makeFieldMetadata({
name: 'createdAt',
type: FieldMetadataType.DATE_TIME,
isCustom: false,
}),
makeFieldMetadata({
name: 'updatedAt',
type: FieldMetadataType.DATE_TIME,
isCustom: false,
}),
makeFieldMetadata({
name: 'name',
type: FieldMetadataType.TEXT,
isCustom: false,
universalIdentifier: labelUid,
}),
];
const result = computeFieldsWidgetViewFieldsAndGroupsToCreate({
objectFlatFieldMetadatas: fields,
viewUniversalIdentifier,
flatApplication,
labelIdentifierFieldMetadataUniversalIdentifier: labelUid,
});
expect(result.flatViewFieldsToCreate).toHaveLength(3);
// The label identifier field must be at the lowest position
const labelField = result.flatViewFieldsToCreate.find(
(vf) => vf.fieldMetadataUniversalIdentifier === labelUid,
);
expect(labelField).toBeDefined();
expect(labelField!.position).toBe(0);
// Other fields should have higher positions
const otherPositions = result.flatViewFieldsToCreate
.filter((vf) => vf.fieldMetadataUniversalIdentifier !== labelUid)
.map((vf) => vf.position);
otherPositions.forEach((pos) => {
expect(pos).toBeGreaterThan(0);
});
});
it('should make RELATION and MORPH_RELATION fields invisible by default', () => {
const fields = [
makeFieldMetadata({
name: 'name',
type: FieldMetadataType.TEXT,
isCustom: false,
}),
makeFieldMetadata({
name: 'company',
type: FieldMetadataType.RELATION,
isCustom: false,
}),
makeFieldMetadata({
name: 'target',
type: FieldMetadataType.MORPH_RELATION,
isCustom: false,
}),
makeFieldMetadata({
name: 'customRelation',
type: FieldMetadataType.RELATION,
isCustom: true,
}),
];
const result = computeFieldsWidgetViewFieldsAndGroupsToCreate({
objectFlatFieldMetadatas: fields,
viewUniversalIdentifier,
flatApplication,
labelIdentifierFieldMetadataUniversalIdentifier: null,
});
const viewFieldForName = result.flatViewFieldsToCreate.find(
(vf) =>
vf.fieldMetadataUniversalIdentifier === fields[0].universalIdentifier,
);
const viewFieldForCompany = result.flatViewFieldsToCreate.find(
(vf) =>
vf.fieldMetadataUniversalIdentifier === fields[1].universalIdentifier,
);
const viewFieldForTarget = result.flatViewFieldsToCreate.find(
(vf) =>
vf.fieldMetadataUniversalIdentifier === fields[2].universalIdentifier,
);
const viewFieldForCustomRelation = result.flatViewFieldsToCreate.find(
(vf) =>
vf.fieldMetadataUniversalIdentifier === fields[3].universalIdentifier,
);
expect(viewFieldForName!.isVisible).toBe(true);
expect(viewFieldForCompany!.isVisible).toBe(false);
expect(viewFieldForTarget!.isVisible).toBe(false);
expect(viewFieldForCustomRelation!.isVisible).toBe(false);
});
});
@@ -0,0 +1,137 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { v4 } from 'uuid';
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
import { DEFAULT_VIEW_FIELD_SIZE } from 'src/engine/metadata-modules/flat-view-field/constants/default-view-field-size.constant';
import { type UniversalFlatFieldMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-field-metadata.type';
import { type UniversalFlatViewFieldGroup } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-view-field-group.type';
import { type UniversalFlatViewField } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-view-field.type';
export const computeFieldsWidgetViewFieldsAndGroupsToCreate = ({
objectFlatFieldMetadatas,
viewUniversalIdentifier,
flatApplication,
labelIdentifierFieldMetadataUniversalIdentifier,
}: {
objectFlatFieldMetadatas: UniversalFlatFieldMetadata[];
viewUniversalIdentifier: string;
flatApplication: FlatApplication;
labelIdentifierFieldMetadataUniversalIdentifier: string | null;
}): {
flatViewFieldGroupsToCreate: UniversalFlatViewFieldGroup[];
flatViewFieldsToCreate: UniversalFlatViewField[];
} => {
const createdAt = new Date().toISOString();
const applicationUniversalIdentifier = flatApplication.universalIdentifier;
const eligibleFields = objectFlatFieldMetadatas.filter(
(field) =>
field.name !== 'deletedAt' &&
field.type !== FieldMetadataType.TS_VECTOR &&
field.type !== FieldMetadataType.POSITION &&
(field.name !== 'id' ||
field.universalIdentifier ===
labelIdentifierFieldMetadataUniversalIdentifier),
);
const standardFields = eligibleFields.filter((field) => !field.isCustom);
const customFields = eligibleFields.filter((field) => field.isCustom);
const sortedStandardFields = [...standardFields].sort((a, b) => {
const aIsLabel =
a.universalIdentifier === labelIdentifierFieldMetadataUniversalIdentifier;
const bIsLabel =
b.universalIdentifier === labelIdentifierFieldMetadataUniversalIdentifier;
if (aIsLabel && !bIsLabel) return -1;
if (!aIsLabel && bIsLabel) return 1;
return 0;
});
const flatViewFieldGroupsToCreate: UniversalFlatViewFieldGroup[] = [];
const flatViewFieldsToCreate: UniversalFlatViewField[] = [];
const generalGroupUniversalIdentifier = v4();
flatViewFieldGroupsToCreate.push({
universalIdentifier: generalGroupUniversalIdentifier,
applicationUniversalIdentifier,
viewUniversalIdentifier,
name: 'General',
position: 0,
isVisible: true,
overrides: null,
viewFieldUniversalIdentifiers: [],
createdAt,
updatedAt: createdAt,
deletedAt: null,
});
sortedStandardFields.forEach((field, index) => {
const isVisible =
field.type !== FieldMetadataType.RELATION &&
field.type !== FieldMetadataType.MORPH_RELATION;
flatViewFieldsToCreate.push({
fieldMetadataUniversalIdentifier: field.universalIdentifier,
viewUniversalIdentifier,
viewFieldGroupUniversalIdentifier: generalGroupUniversalIdentifier,
createdAt,
updatedAt: createdAt,
deletedAt: null,
universalIdentifier: v4(),
isVisible,
size: DEFAULT_VIEW_FIELD_SIZE,
position: index,
aggregateOperation: null,
universalOverrides: null,
applicationUniversalIdentifier,
});
});
if (customFields.length > 0) {
const otherGroupUniversalIdentifier = v4();
flatViewFieldGroupsToCreate.push({
universalIdentifier: otherGroupUniversalIdentifier,
applicationUniversalIdentifier,
viewUniversalIdentifier,
name: 'Other',
position: 1,
isVisible: true,
overrides: null,
viewFieldUniversalIdentifiers: [],
createdAt,
updatedAt: createdAt,
deletedAt: null,
});
customFields.forEach((field, index) => {
const isVisible =
field.type !== FieldMetadataType.RELATION &&
field.type !== FieldMetadataType.MORPH_RELATION;
flatViewFieldsToCreate.push({
fieldMetadataUniversalIdentifier: field.universalIdentifier,
viewUniversalIdentifier,
viewFieldGroupUniversalIdentifier: otherGroupUniversalIdentifier,
createdAt,
updatedAt: createdAt,
deletedAt: null,
universalIdentifier: v4(),
isVisible,
size: DEFAULT_VIEW_FIELD_SIZE,
position: index,
aggregateOperation: null,
universalOverrides: null,
applicationUniversalIdentifier,
});
});
}
return {
flatViewFieldGroupsToCreate,
flatViewFieldsToCreate,
};
};