Fallback to field metadata (#19131)

Rely on the field metadata items to always display all object's fields
in the fields widget configuration editor. If fields are missing in the
returned view fields, we add the missing fields through object metadata.


https://github.com/user-attachments/assets/3c4d45e8-05d0-4943-be4b-bcf1e310155c
This commit is contained in:
Baptiste Devessier
2026-03-31 21:00:16 +02:00
committed by GitHub
parent 5bbfce7789
commit c11e4ece39
17 changed files with 591 additions and 68 deletions

View File

@@ -3766,8 +3766,13 @@ input UpsertFieldsWidgetGroupInput {
}
input UpsertFieldsWidgetFieldInput {
"""The id of the view field"""
viewFieldId: UUID!
"""The id of the view field. Required if fieldMetadataId is not provided."""
viewFieldId: UUID
"""
The id of the field metadata. Used to create a new view field when viewFieldId is not provided.
"""
fieldMetadataId: UUID
isVisible: Boolean!
position: Float!
}

View File

@@ -6258,8 +6258,10 @@ fields?: (UpsertFieldsWidgetFieldInput[] | null)}
export interface UpsertFieldsWidgetGroupInput {id: Scalars['UUID'],name: Scalars['String'],position: Scalars['Float'],isVisible: Scalars['Boolean'],fields: UpsertFieldsWidgetFieldInput[]}
export interface UpsertFieldsWidgetFieldInput {
/** The id of the view field */
viewFieldId: Scalars['UUID'],isVisible: Scalars['Boolean'],position: Scalars['Float']}
/** The id of the view field. Required if fieldMetadataId is not provided. */
viewFieldId?: (Scalars['UUID'] | null),
/** The id of the field metadata. Used to create a new view field when viewFieldId is not provided. */
fieldMetadataId?: (Scalars['UUID'] | null),isVisible: Scalars['Boolean'],position: Scalars['Float']}
export interface CreateApiKeyInput {name: Scalars['String'],expiresAt: Scalars['String'],revokedAt?: (Scalars['String'] | null),roleId: Scalars['UUID']}

View File

@@ -9556,6 +9556,9 @@ export default {
"viewFieldId": [
3
],
"fieldMetadataId": [
3
],
"isVisible": [
6
],

View File

@@ -5514,10 +5514,12 @@ export type UpsertFieldPermissionsInput = {
};
export type UpsertFieldsWidgetFieldInput = {
/** The id of the field metadata. Used to create a new view field when viewFieldId is not provided. */
fieldMetadataId?: InputMaybe<Scalars['UUID']>;
isVisible: Scalars['Boolean'];
position: Scalars['Float'];
/** The id of the view field */
viewFieldId: Scalars['UUID'];
/** The id of the view field. Required if fieldMetadataId is not provided. */
viewFieldId?: InputMaybe<Scalars['UUID']>;
};
export type UpsertFieldsWidgetGroupInput = {

View File

@@ -73,19 +73,15 @@ export const useSaveFieldsWidgetGroups = () => {
name: group.name,
position: group.position,
isVisible: group.isVisible,
fields: group.fields.flatMap((field) => {
if (!isDefined(field.viewFieldId)) {
return [];
}
return [
{
viewFieldId: field.viewFieldId,
isVisible: field.isVisible,
position: field.position,
},
];
}),
fields: group.fields.map((field) => ({
...(isDefined(field.viewFieldId)
? { viewFieldId: field.viewFieldId }
: {
fieldMetadataId: field.fieldMetadataItem.id,
}),
isVisible: field.isVisible,
position: field.position,
})),
})),
},
},
@@ -97,19 +93,15 @@ export const useSaveFieldsWidgetGroups = () => {
variables: {
input: {
widgetId,
fields: ungroupedFields.flatMap((field) => {
if (!isDefined(field.viewFieldId)) {
return [];
}
return [
{
viewFieldId: field.viewFieldId,
isVisible: field.isVisible,
position: field.position,
},
];
}),
fields: ungroupedFields.map((field) => ({
...(isDefined(field.viewFieldId)
? { viewFieldId: field.viewFieldId }
: {
fieldMetadataId: field.fieldMetadataItem.id,
}),
isVisible: field.isVisible,
position: field.position,
})),
},
},
});

View File

@@ -1,3 +1,4 @@
import { useLabelIdentifierFieldMetadataItem } from '@/object-metadata/hooks/useLabelIdentifierFieldMetadataItem';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { type FieldsWidgetEditorMode } from '@/page-layout/widgets/fields/types/FieldsWidgetEditorMode';
import {
@@ -6,7 +7,11 @@ import {
} from '@/page-layout/widgets/fields/types/FieldsWidgetGroup';
import { useViewById } from '@/views/hooks/useViewById';
import { useMemo } from 'react';
import { isDefined, isNonEmptyArray } from 'twenty-shared/utils';
import {
isDefined,
isFieldMetadataEligibleForFieldsWidget,
isNonEmptyArray,
} from 'twenty-shared/utils';
type UseFieldsWidgetEditorGroupsDataParams = {
viewId: string | null;
@@ -28,6 +33,10 @@ export const useFieldsWidgetEditorGroupsData = ({
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});
const { labelIdentifierFieldMetadataItem } =
useLabelIdentifierFieldMetadataItem({
objectNameSingular,
});
const result = useMemo<
Pick<
@@ -39,6 +48,45 @@ export const useFieldsWidgetEditorGroupsData = ({
return { groups: [], ungroupedFields: [], editorMode: 'ungrouped' };
}
const eligibleFieldMetadataIds = new Set(
objectMetadataItem.fields
.filter((field) =>
isFieldMetadataEligibleForFieldsWidget({
fieldName: field.name,
fieldType: field.type,
isLabelIdentifierField:
field.id === labelIdentifierFieldMetadataItem?.id,
}),
)
.map((field) => field.id),
);
const buildMissingFields = ({
existingFieldMetadataIds,
startGlobalIndex,
startPosition,
}: {
existingFieldMetadataIds: Set<string>;
startGlobalIndex: number;
startPosition: number;
}): FieldsWidgetGroupField[] => {
let globalIndex = startGlobalIndex;
let position = startPosition;
return objectMetadataItem.fields
.filter(
(field) =>
!existingFieldMetadataIds.has(field.id) &&
eligibleFieldMetadataIds.has(field.id),
)
.map((field) => ({
fieldMetadataItem: field,
position: position++,
isVisible: false,
globalIndex: globalIndex++,
}));
};
if (isDefined(view) && isNonEmptyArray(view.viewFieldGroups)) {
const viewFieldGroups = view.viewFieldGroups;
@@ -47,13 +95,14 @@ export const useFieldsWidgetEditorGroupsData = ({
);
let globalIndex = 0;
const existingFieldMetadataIds = new Set<string>();
const groups = sortedGroups.map((group) => {
const groupFields = [...(group.viewFields ?? [])].sort(
(a, b) => a.position - b.position,
);
const fields = groupFields
const fields: FieldsWidgetGroupField[] = groupFields
.map((viewField) => {
const fieldMetadataItem = objectMetadataItem.fields.find(
(f) => f.id === viewField.fieldMetadataId,
@@ -63,6 +112,8 @@ export const useFieldsWidgetEditorGroupsData = ({
return null;
}
existingFieldMetadataIds.add(viewField.fieldMetadataId);
return {
fieldMetadataItem,
position: viewField.position,
@@ -82,11 +133,28 @@ export const useFieldsWidgetEditorGroupsData = ({
};
});
const lastGroup = groups[groups.length - 1];
const lastFieldPosition =
lastGroup.fields.length > 0
? Math.max(...lastGroup.fields.map((f) => f.position)) + 1
: 0;
const missingFields = buildMissingFields({
existingFieldMetadataIds,
startGlobalIndex: globalIndex,
startPosition: lastFieldPosition,
});
if (missingFields.length > 0) {
lastGroup.fields = [...lastGroup.fields, ...missingFields];
}
return { groups, ungroupedFields: [], editorMode: 'grouped' };
}
if (isDefined(view) && view.viewFields.length > 0) {
let globalIndex = 0;
const existingFieldMetadataIds = new Set<string>();
const fields = [...view.viewFields]
.sort((a, b) => a.position - b.position)
@@ -99,6 +167,8 @@ export const useFieldsWidgetEditorGroupsData = ({
return null;
}
existingFieldMetadataIds.add(viewField.fieldMetadataId);
return {
fieldMetadataItem,
position: viewField.position,
@@ -109,13 +179,28 @@ export const useFieldsWidgetEditorGroupsData = ({
})
.filter(isDefined);
if (fields.length > 0) {
return { groups: [], ungroupedFields: fields, editorMode: 'ungrouped' };
const lastFieldPosition =
fields.length > 0 ? Math.max(...fields.map((f) => f.position)) + 1 : 0;
const missingFields = buildMissingFields({
existingFieldMetadataIds,
startGlobalIndex: globalIndex,
startPosition: lastFieldPosition,
});
const allFields = [...fields, ...missingFields];
if (allFields.length > 0) {
return {
groups: [],
ungroupedFields: allFields,
editorMode: 'ungrouped',
};
}
}
return { groups: [], ungroupedFields: [], editorMode: 'ungrouped' };
}, [objectMetadataItem, view]);
}, [objectMetadataItem, view, labelIdentifierFieldMetadataItem]);
return {
...result,

View File

@@ -1,15 +1,30 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsBoolean, IsNotEmpty, IsNumber, IsUUID } from 'class-validator';
import { IsBoolean, IsNumber, IsOptional, IsUUID } from 'class-validator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { AtLeastOneOf } from 'src/engine/metadata-modules/view-field-group/dtos/validators/at-least-one-of.validator';
@InputType()
@AtLeastOneOf(['viewFieldId', 'fieldMetadataId'])
export class UpsertFieldsWidgetFieldInput {
@IsOptional()
@IsUUID()
@IsNotEmpty()
@Field(() => UUIDScalarType, { description: 'The id of the view field' })
viewFieldId: string;
@Field(() => UUIDScalarType, {
nullable: true,
description:
'The id of the view field. Required if fieldMetadataId is not provided.',
})
viewFieldId?: string;
@IsOptional()
@IsUUID()
@Field(() => UUIDScalarType, {
nullable: true,
description:
'The id of the field metadata. Used to create a new view field when viewFieldId is not provided.',
})
fieldMetadataId?: string;
@IsBoolean()
@Field()

View File

@@ -0,0 +1,39 @@
import {
registerDecorator,
type ValidationArguments,
type ValidationOptions,
ValidatorConstraint,
type ValidatorConstraintInterface,
} from 'class-validator';
import { isDefined } from 'twenty-shared/utils';
@ValidatorConstraint({ async: false })
export class AtLeastOneOfConstraint implements ValidatorConstraintInterface {
validate(_value: unknown, args: ValidationArguments) {
const [properties] = args.constraints as [string[]];
const object = args.object as Record<string, unknown>;
return properties.some((property) => isDefined(object[property]));
}
defaultMessage(args: ValidationArguments) {
const [properties] = args.constraints as [string[]];
return `At least one of the following properties must be provided: ${properties.join(', ')}`;
}
}
export const AtLeastOneOf = (
properties: string[],
validationOptions?: ValidationOptions,
): ClassDecorator => {
return (target) => {
registerDecorator({
target,
propertyName: properties[0],
options: validationOptions,
constraints: [properties],
validator: AtLeastOneOfConstraint,
});
};
};

View File

@@ -1,4 +1,4 @@
import { UseFilters, UseGuards } from '@nestjs/common';
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
import {
Args,
Context,
@@ -13,6 +13,7 @@ import { isArray } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { type IDataloaders } from 'src/engine/dataloaders/dataloader.interface';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
@@ -148,6 +149,7 @@ export class ViewFieldGroupResolver {
@Mutation(() => ViewDTO)
@UseGuards(NoPermissionGuard)
@UsePipes(ResolverValidationPipe)
async upsertFieldsWidget(
@Args('input') input: UpsertFieldsWidgetInput,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,

View File

@@ -2,17 +2,25 @@ import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { t } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
import {
isDefined,
isFieldMetadataEligibleForFieldsWidget,
} from 'twenty-shared/utils';
import { IsNull, Repository } from 'typeorm';
import { v4 } from 'uuid';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { addFlatEntityToFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/add-flat-entity-to-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 { resolveEntityRelationUniversalIdentifiers } from 'src/engine/metadata-modules/flat-entity/utils/resolve-entity-relation-universal-identifiers.util';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { isFlatPageLayoutWidgetConfigurationOfType } from 'src/engine/metadata-modules/flat-page-layout-widget/utils/is-flat-page-layout-widget-configuration-of-type.util';
import { type FlatViewFieldGroupMaps } from 'src/engine/metadata-modules/flat-view-field-group/types/flat-view-field-group-maps.type';
import { type FlatViewFieldGroup } from 'src/engine/metadata-modules/flat-view-field-group/types/flat-view-field-group.type';
import { DEFAULT_VIEW_FIELD_SIZE } from 'src/engine/metadata-modules/flat-view-field/constants/default-view-field-size.constant';
import { type FlatViewField } from 'src/engine/metadata-modules/flat-view-field/types/flat-view-field.type';
import { fromViewFieldOverridesToUniversalOverrides } from 'src/engine/metadata-modules/flat-view-field/utils/from-view-field-overrides-to-universal-overrides.util';
import { type FlatViewMaps } from 'src/engine/metadata-modules/flat-view/types/flat-view-maps.type';
@@ -66,6 +74,8 @@ export class FieldsWidgetUpsertService {
const {
flatPageLayoutWidgetMaps,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
flatViewFieldGroupMaps,
flatViewFieldMaps,
flatViewMaps,
@@ -75,6 +85,8 @@ export class FieldsWidgetUpsertService {
workspaceId,
flatMapsKeys: [
'flatPageLayoutWidgetMaps',
'flatFieldMetadataMaps',
'flatObjectMetadataMaps',
'flatViewFieldGroupMaps',
'flatViewFieldMaps',
'flatViewMaps',
@@ -109,6 +121,22 @@ export class FieldsWidgetUpsertService {
);
}
const flatView = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: viewId,
flatEntityMaps: flatViewMaps,
});
const objectMetadata = isDefined(flatView)
? findFlatEntityByIdInFlatEntityMaps({
flatEntityId: flatView.objectMetadataId,
flatEntityMaps:
flatObjectMetadataMaps as FlatEntityMaps<FlatObjectMetadata>,
})
: undefined;
const labelIdentifierFieldMetadataId =
objectMetadata?.labelIdentifierFieldMetadataId ?? null;
const existingGroups = Object.values(
flatViewFieldGroupMaps.byUniversalIdentifier,
)
@@ -135,6 +163,8 @@ export class FieldsWidgetUpsertService {
applicationId: workspaceCustomFlatApplication.id,
applicationUniversalIdentifier:
workspaceCustomFlatApplication.universalIdentifier,
labelIdentifierFieldMetadataId,
flatFieldMetadataMaps,
flatViewMaps,
flatViewFieldGroupMaps,
});
@@ -143,9 +173,14 @@ export class FieldsWidgetUpsertService {
inputFields: input.fields!,
existingGroups,
existingViewFields,
viewId,
workspaceId,
applicationId: workspaceCustomFlatApplication.id,
applicationUniversalIdentifier:
workspaceCustomFlatApplication.universalIdentifier,
labelIdentifierFieldMetadataId,
flatFieldMetadataMaps,
flatViewMaps,
});
}
@@ -171,6 +206,8 @@ export class FieldsWidgetUpsertService {
workspaceId,
applicationId,
applicationUniversalIdentifier,
labelIdentifierFieldMetadataId,
flatFieldMetadataMaps,
flatViewMaps,
flatViewFieldGroupMaps,
}: {
@@ -181,6 +218,8 @@ export class FieldsWidgetUpsertService {
workspaceId: string;
applicationId: string;
applicationUniversalIdentifier: string;
labelIdentifierFieldMetadataId: string | null;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
flatViewMaps: FlatViewMaps;
flatViewFieldGroupMaps: FlatViewFieldGroupMaps;
}): Promise<void> {
@@ -362,6 +401,77 @@ export class FieldsWidgetUpsertService {
return [updatedField];
});
const viewFieldsToCreate: FlatViewField[] = [];
for (const inputGroup of inputGroups) {
for (const inputField of inputGroup.fields) {
if (
isDefined(inputField.viewFieldId) ||
!isDefined(inputField.fieldMetadataId)
) {
continue;
}
const fieldMetadata = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: inputField.fieldMetadataId,
flatEntityMaps: flatFieldMetadataMaps,
});
if (
!isDefined(fieldMetadata) ||
!isFieldMetadataEligibleForFieldsWidget({
fieldName: fieldMetadata.name,
fieldType: fieldMetadata.type,
isLabelIdentifierField:
fieldMetadata.id === labelIdentifierFieldMetadataId,
})
) {
continue;
}
const {
fieldMetadataUniversalIdentifier,
viewUniversalIdentifier,
viewFieldGroupUniversalIdentifier,
} = resolveEntityRelationUniversalIdentifiers({
metadataName: 'viewField',
foreignKeyValues: {
fieldMetadataId: inputField.fieldMetadataId,
viewId,
viewFieldGroupId: inputGroup.id,
},
flatEntityMaps: {
flatFieldMetadataMaps,
flatViewMaps,
flatViewFieldGroupMaps: optimisticFlatViewFieldGroupMaps,
},
});
viewFieldsToCreate.push({
id: v4(),
workspaceId,
applicationId,
universalIdentifier: v4(),
applicationUniversalIdentifier,
fieldMetadataId: inputField.fieldMetadataId,
fieldMetadataUniversalIdentifier,
viewId,
viewUniversalIdentifier,
viewFieldGroupId: inputGroup.id,
viewFieldGroupUniversalIdentifier,
isVisible: inputField.isVisible,
size: DEFAULT_VIEW_FIELD_SIZE,
position: inputField.position,
aggregateOperation: null,
overrides: null,
universalOverrides: null,
createdAt: now,
updatedAt: now,
deletedAt: null,
});
}
}
const fieldsWithStaleGroupOverrides =
this.buildFieldUpdatesForStaleGroupOverrides({
existingViewFields,
@@ -382,7 +492,7 @@ export class FieldsWidgetUpsertService {
flatEntityToUpdate: groupsToUpdate,
},
viewField: {
flatEntityToCreate: [],
flatEntityToCreate: viewFieldsToCreate,
flatEntityToDelete: [],
flatEntityToUpdate: [
...viewFieldsToUpdate,
@@ -408,14 +518,24 @@ export class FieldsWidgetUpsertService {
inputFields,
existingGroups,
existingViewFields,
viewId,
workspaceId,
applicationId,
applicationUniversalIdentifier,
labelIdentifierFieldMetadataId,
flatFieldMetadataMaps,
flatViewMaps,
}: {
inputFields: UpsertFieldsWidgetFieldInput[];
existingGroups: FlatViewFieldGroup[];
existingViewFields: FlatViewField[];
viewId: string;
workspaceId: string;
applicationId: string;
applicationUniversalIdentifier: string;
labelIdentifierFieldMetadataId: string | null;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
flatViewMaps: FlatViewMaps;
}): Promise<void> {
const now = new Date().toISOString();
@@ -494,6 +614,68 @@ export class FieldsWidgetUpsertService {
return [updatedField];
});
const viewFieldsToCreate: FlatViewField[] = inputFields
.filter((inputField) => {
if (
isDefined(inputField.viewFieldId) ||
!isDefined(inputField.fieldMetadataId)
) {
return false;
}
const fieldMetadata = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: inputField.fieldMetadataId,
flatEntityMaps: flatFieldMetadataMaps,
});
return (
isDefined(fieldMetadata) &&
isFieldMetadataEligibleForFieldsWidget({
fieldName: fieldMetadata.name,
fieldType: fieldMetadata.type,
isLabelIdentifierField:
fieldMetadata.id === labelIdentifierFieldMetadataId,
})
);
})
.map((inputField) => {
const { fieldMetadataUniversalIdentifier, viewUniversalIdentifier } =
resolveEntityRelationUniversalIdentifiers({
metadataName: 'viewField',
foreignKeyValues: {
fieldMetadataId: inputField.fieldMetadataId!,
viewId,
},
flatEntityMaps: {
flatFieldMetadataMaps,
flatViewMaps,
},
});
return {
id: v4(),
workspaceId,
applicationId,
universalIdentifier: v4(),
applicationUniversalIdentifier,
fieldMetadataId: inputField.fieldMetadataId!,
fieldMetadataUniversalIdentifier,
viewId,
viewUniversalIdentifier,
viewFieldGroupId: null,
viewFieldGroupUniversalIdentifier: null,
isVisible: inputField.isVisible,
size: DEFAULT_VIEW_FIELD_SIZE,
position: inputField.position,
aggregateOperation: null,
overrides: null,
universalOverrides: null,
createdAt: now,
updatedAt: now,
deletedAt: null,
};
});
const fieldsWithStaleGroupOverrides =
this.buildFieldUpdatesForStaleGroupOverrides({
existingViewFields,
@@ -514,7 +696,7 @@ export class FieldsWidgetUpsertService {
flatEntityToUpdate: [],
},
viewField: {
flatEntityToCreate: [],
flatEntityToCreate: viewFieldsToCreate,
flatEntityToDelete: [],
flatEntityToUpdate: [
...viewFieldsToUpdate,

View File

@@ -217,7 +217,7 @@ describe('computeFieldsWidgetViewFieldsAndGroupsToCreate', () => {
expect(result.flatViewFieldsToCreate).toHaveLength(1);
});
it('should exclude id field unless it is the label identifier', () => {
it('should always exclude id field even when it is the label identifier', () => {
const idUid = v4();
const fields = [
@@ -244,7 +244,7 @@ describe('computeFieldsWidgetViewFieldsAndGroupsToCreate', () => {
expect(resultWithout.flatViewFieldsToCreate).toHaveLength(1);
// With label identifier match: id included
// With id as label identifier: id still excluded (label identifier fields are excluded)
const resultWith = computeFieldsWidgetViewFieldsAndGroupsToCreate({
objectFlatFieldMetadatas: fields,
viewUniversalIdentifier,
@@ -252,7 +252,7 @@ describe('computeFieldsWidgetViewFieldsAndGroupsToCreate', () => {
labelIdentifierFieldMetadataUniversalIdentifier: idUid,
});
expect(resultWith.flatViewFieldsToCreate).toHaveLength(2);
expect(resultWith.flatViewFieldsToCreate).toHaveLength(1);
});
it('should set correct applicationUniversalIdentifier on all entities', () => {
@@ -339,24 +339,15 @@ describe('computeFieldsWidgetViewFieldsAndGroupsToCreate', () => {
labelIdentifierFieldMetadataUniversalIdentifier: labelUid,
});
expect(result.flatViewFieldsToCreate).toHaveLength(3);
// Label identifier field is excluded by isFieldMetadataEligibleForFieldsWidget
expect(result.flatViewFieldsToCreate).toHaveLength(2);
// The label identifier field must be at the lowest position
// The label identifier field should not be in the results
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);
});
expect(labelField).toBeUndefined();
});
it('should make RELATION and MORPH_RELATION fields invisible by default', () => {

View File

@@ -0,0 +1,75 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { isFieldMetadataEligibleForFieldsWidget } from 'twenty-shared/utils';
describe('isFieldMetadataEligibleForFieldsWidget', () => {
it('should exclude deletedAt field', () => {
expect(
isFieldMetadataEligibleForFieldsWidget({
fieldName: 'deletedAt',
fieldType: FieldMetadataType.DATE_TIME,
isLabelIdentifierField: false,
}),
).toBe(false);
});
it('should exclude TS_VECTOR fields', () => {
expect(
isFieldMetadataEligibleForFieldsWidget({
fieldName: 'searchVector',
fieldType: FieldMetadataType.TS_VECTOR,
isLabelIdentifierField: false,
}),
).toBe(false);
});
it('should exclude POSITION fields', () => {
expect(
isFieldMetadataEligibleForFieldsWidget({
fieldName: 'position',
fieldType: FieldMetadataType.POSITION,
isLabelIdentifierField: false,
}),
).toBe(false);
});
it('should exclude id field when it is not the label identifier', () => {
expect(
isFieldMetadataEligibleForFieldsWidget({
fieldName: 'id',
fieldType: FieldMetadataType.UUID,
isLabelIdentifierField: false,
}),
).toBe(false);
});
it('should exclude id field even when it is the label identifier', () => {
expect(
isFieldMetadataEligibleForFieldsWidget({
fieldName: 'id',
fieldType: FieldMetadataType.UUID,
isLabelIdentifierField: true,
}),
).toBe(false);
});
it('should include a normal field', () => {
expect(
isFieldMetadataEligibleForFieldsWidget({
fieldName: 'name',
fieldType: FieldMetadataType.TEXT,
isLabelIdentifierField: false,
}),
).toBe(true);
});
it('should include createdAt field', () => {
expect(
isFieldMetadataEligibleForFieldsWidget({
fieldName: 'createdAt',
fieldType: FieldMetadataType.DATE_TIME,
isLabelIdentifierField: false,
}),
).toBe(true);
});
});

View File

@@ -6,6 +6,7 @@ import { DEFAULT_VIEW_FIELD_SIZE } from 'src/engine/metadata-modules/flat-view-f
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';
import { isFieldMetadataEligibleForFieldsWidget } from 'twenty-shared/utils';
export const computeFieldsWidgetViewFieldsAndGroupsToCreate = ({
objectFlatFieldMetadatas,
@@ -24,14 +25,14 @@ export const computeFieldsWidgetViewFieldsAndGroupsToCreate = ({
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' ||
const eligibleFields = objectFlatFieldMetadatas.filter((field) =>
isFieldMetadataEligibleForFieldsWidget({
fieldName: field.name,
fieldType: field.type,
isLabelIdentifierField:
field.universalIdentifier ===
labelIdentifierFieldMetadataUniversalIdentifier),
labelIdentifierFieldMetadataUniversalIdentifier,
}),
);
const standardFields = eligibleFields.filter((field) => !field.isCustom);

View File

@@ -0,0 +1,94 @@
import { FieldMetadataType } from '@/types';
import { isFieldMetadataEligibleForFieldsWidget } from '@/utils/fieldMetadata/isFieldMetadataEligibleForFieldsWidget';
describe('isFieldMetadataEligibleForFieldsWidget', () => {
it('should exclude deletedAt field', () => {
expect(
isFieldMetadataEligibleForFieldsWidget({
fieldName: 'deletedAt',
fieldType: FieldMetadataType.DATE_TIME,
isLabelIdentifierField: false,
}),
).toBe(false);
});
it('should exclude TS_VECTOR fields', () => {
expect(
isFieldMetadataEligibleForFieldsWidget({
fieldName: 'searchVector',
fieldType: FieldMetadataType.TS_VECTOR,
isLabelIdentifierField: false,
}),
).toBe(false);
});
it('should exclude POSITION fields', () => {
expect(
isFieldMetadataEligibleForFieldsWidget({
fieldName: 'position',
fieldType: FieldMetadataType.POSITION,
isLabelIdentifierField: false,
}),
).toBe(false);
});
it('should exclude id field when it is not the label identifier', () => {
expect(
isFieldMetadataEligibleForFieldsWidget({
fieldName: 'id',
fieldType: FieldMetadataType.UUID,
isLabelIdentifierField: false,
}),
).toBe(false);
});
it('should exclude id field even when it is the label identifier', () => {
expect(
isFieldMetadataEligibleForFieldsWidget({
fieldName: 'id',
fieldType: FieldMetadataType.UUID,
isLabelIdentifierField: true,
}),
).toBe(false);
});
it('should include a normal field', () => {
expect(
isFieldMetadataEligibleForFieldsWidget({
fieldName: 'name',
fieldType: FieldMetadataType.TEXT,
isLabelIdentifierField: false,
}),
).toBe(true);
});
it('should include createdAt field', () => {
expect(
isFieldMetadataEligibleForFieldsWidget({
fieldName: 'createdAt',
fieldType: FieldMetadataType.DATE_TIME,
isLabelIdentifierField: false,
}),
).toBe(true);
});
it('should exclude a label identifier field', () => {
expect(
isFieldMetadataEligibleForFieldsWidget({
fieldName: 'name',
fieldType: FieldMetadataType.TEXT,
isLabelIdentifierField: true,
}),
).toBe(false);
});
it('should exclude deletedAt field even when it is the label identifier', () => {
expect(
isFieldMetadataEligibleForFieldsWidget({
fieldName: 'deletedAt',
fieldType: FieldMetadataType.DATE_TIME,
isLabelIdentifierField: true,
}),
).toBe(false);
});
});

View File

@@ -1,5 +1,6 @@
export * from './isFieldMetadataArrayKind';
export * from './isFieldMetadataDateKind';
export * from './isFieldMetadataEligibleForFieldsWidget';
export * from './isFieldMetadataNumericKind';
export * from './isFieldMetadataSelectKind';
export * from './isFieldMetadataTextKind';

View File

@@ -0,0 +1,33 @@
import { FieldMetadataType } from '@/types';
export const isFieldMetadataEligibleForFieldsWidget = ({
fieldName,
fieldType,
isLabelIdentifierField,
}: {
fieldName: string;
fieldType: FieldMetadataType;
isLabelIdentifierField: boolean;
}): boolean => {
if (fieldName === 'deletedAt') {
return false;
}
if (fieldType === FieldMetadataType.TS_VECTOR) {
return false;
}
if (fieldType === FieldMetadataType.POSITION) {
return false;
}
if (fieldName === 'id' && !isLabelIdentifierField) {
return false;
}
if (isLabelIdentifierField) {
return false;
}
return true;
};

View File

@@ -46,6 +46,7 @@ export { extractAndSanitizeObjectStringFields } from './extractAndSanitizeObject
export { computeMorphRelationFieldName } from './fieldMetadata/compute-morph-relation-field-name';
export { isFieldMetadataArrayKind } from './fieldMetadata/isFieldMetadataArrayKind';
export { isFieldMetadataDateKind } from './fieldMetadata/isFieldMetadataDateKind';
export { isFieldMetadataEligibleForFieldsWidget } from './fieldMetadata/isFieldMetadataEligibleForFieldsWidget';
export { isFieldMetadataNumericKind } from './fieldMetadata/isFieldMetadataNumericKind';
export { isFieldMetadataSelectKind } from './fieldMetadata/isFieldMetadataSelectKind';
export { isFieldMetadataTextKind } from './fieldMetadata/isFieldMetadataTextKind';