From 872c0e97f665eeb3386cae3224c1c8a624fb523d Mon Sep 17 00:00:00 2001 From: Paul Rastoin <45004772+prastoin@users.noreply.github.com> Date: Thu, 20 Mar 2025 18:38:19 +0100 Subject: [PATCH] [BUGFIX] `GenerateDefaultRecordChipData` returns `RecordChipData` (#11071) # Introduction closes https://github.com/twentyhq/twenty/issues/11030, might not fix the issue as it seems to be related to the passed record to `identifierChipGeneratorPerObject` about to dig deeper in this generator code => found nothing revelant doubled check each `RecordChip` invocation that could provide undefined record Fixed wrong default generated `RecordChipData` signature ## Reproducibility I've only been able to reproduce the bug in production using the very same opportunity than within the issue, but not all the time https://crm.twenty-internal.com/objects/opportunities?viewId=b709d3d1-2dd2-455d-ba73-784f3ab00883 ```json // Removed timelineActivities to prevent linking ids { "data": { "opportunity": { "__typename": "Opportunity", "closeDate": null, "company": null, "companyId": null, "createdAt": "2024-06-17T09:45:22.357Z", "deletedAt": null, "id": "006a22dd-6bd6-4247-a24b-42fb164cd48c", "name": "test", "pointOfContact": null, "pointOfContactId": null, "position": 0, "probability": "0", "stage": "NEW_STAGE", "updatedAt": "2025-03-20T16:27:51.927Z", "amount": { "__typename": "Currency", "amountMicros": null, "currencyCode": "USD" }, "attachments": { "__typename": "AttachmentConnection", "edges": [] }, "createdBy": { "__typename": "Actor", "source": "MANUAL", "workspaceMemberId": null, "name": "", "context": {} }, "favorites": { "__typename": "FavoriteConnection", "edges": [] }, "taskTargets": { "__typename": "TaskTargetConnection", "edges": [] }, "noteTargets": { "__typename": "NoteTargetConnection", "edges": [ { "__typename": "NoteTargetEdge", "node": { "__typename": "NoteTarget", "appEventId": null, "companyId": null, "createdAt": "2025-01-22T17:11:07.801Z", "deletedAt": null, "feedbackId": null, "id": "2e8eca1c-e2c2-425a-93fc-ef2aeb65f410", "issueId": null, "listingId": null, "noteId": "ab586b51-6931-4a4a-9c24-0d16226211b2", "opportunityId": "006a22dd-6bd6-4247-a24b-42fb164cd48c", "personId": null, "somethingId": null, "testId": null, "updatedAt": "2025-01-22T17:11:07.801Z" } } ] }, } } } ``` --- .../PreComputedChipGeneratorsContext.ts | 5 +- .../utils/generateDefaultRecordChipData.ts | 20 ++++++-- .../object-record/hooks/useRecordChipData.ts | 33 ++++++++---- .../RelationFromManyFieldDisplay.tsx | 50 ++++++++----------- .../hooks/useRelationFromManyFieldDisplay.ts | 18 +++++-- .../hooks/useRelationToOneFieldDisplay.ts | 20 ++++++-- 6 files changed, 93 insertions(+), 53 deletions(-) diff --git a/packages/twenty-front/src/modules/object-metadata/contexts/PreComputedChipGeneratorsContext.ts b/packages/twenty-front/src/modules/object-metadata/contexts/PreComputedChipGeneratorsContext.ts index ed7b734bc..1b1df0e70 100644 --- a/packages/twenty-front/src/modules/object-metadata/contexts/PreComputedChipGeneratorsContext.ts +++ b/packages/twenty-front/src/modules/object-metadata/contexts/PreComputedChipGeneratorsContext.ts @@ -8,9 +8,8 @@ export type ChipGeneratorPerObjectNameSingularPerFieldName = Record< Record RecordChipData> >; -export type IdentifierChipGeneratorPerObject = Record< - string, - (record: ObjectRecord) => RecordChipData +export type IdentifierChipGeneratorPerObject = Partial< + Record RecordChipData> >; export type PreComputedChipGeneratorsContextProps = { diff --git a/packages/twenty-front/src/modules/object-metadata/utils/generateDefaultRecordChipData.ts b/packages/twenty-front/src/modules/object-metadata/utils/generateDefaultRecordChipData.ts index 5b3acd869..0698f587d 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/generateDefaultRecordChipData.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/generateDefaultRecordChipData.ts @@ -1,15 +1,25 @@ +import { RecordChipData } from '@/object-record/record-field/types/RecordChipData'; import { isFieldFullNameValue } from '@/object-record/record-field/types/guards/isFieldFullNameValue'; import { ObjectRecord } from '@/object-record/types/ObjectRecord'; -export const generateDefaultRecordChipData = (record: ObjectRecord) => { +type GenerateDefaultRecordChipDataArgs = { + record: ObjectRecord; + objectNameSingular: string; +}; +export const generateDefaultRecordChipData = ({ + objectNameSingular, + record, +}: GenerateDefaultRecordChipDataArgs): RecordChipData => { const name = isFieldFullNameValue(record.name) - ? record.name.firstName + ' ' + record.name.lastName + ? `${record.name.firstName} ${record.name.lastName}` : (record.name ?? ''); return { - name, - avatarUrl: name, avatarType: 'rounded', - linkToShowPage: false, + avatarUrl: name, + isLabelIdentifier: false, + name, + objectNameSingular, + recordId: record.id, }; }; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useRecordChipData.ts b/packages/twenty-front/src/modules/object-record/hooks/useRecordChipData.ts index 7ade7cb90..e3ee43edb 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useRecordChipData.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useRecordChipData.ts @@ -1,24 +1,37 @@ import { PreComputedChipGeneratorsContext } from '@/object-metadata/contexts/PreComputedChipGeneratorsContext'; import { generateDefaultRecordChipData } from '@/object-metadata/utils/generateDefaultRecordChipData'; +import { RecordChipData } from '@/object-record/record-field/types/RecordChipData'; import { ObjectRecord } from '@/object-record/types/ObjectRecord'; import { useContext } from 'react'; +import { isDefined } from 'twenty-shared'; +type UseRecordChipDataArgs = { + objectNameSingular: string; + record: ObjectRecord; +}; +type UseRecordChipDataReturnType = { + recordChipData: RecordChipData; +}; export const useRecordChipData = ({ objectNameSingular, record, -}: { - objectNameSingular: string; - record: ObjectRecord; -}) => { +}: UseRecordChipDataArgs): UseRecordChipDataReturnType => { const { identifierChipGeneratorPerObject } = useContext( PreComputedChipGeneratorsContext, ); - const generateRecordChipData = - identifierChipGeneratorPerObject[objectNameSingular] ?? - generateDefaultRecordChipData; + const identifierChipGenerator = + identifierChipGeneratorPerObject[objectNameSingular]; + if (isDefined(identifierChipGenerator)) { + return { + recordChipData: identifierChipGenerator(record), + }; + } - const recordChipData = generateRecordChipData(record); - - return { recordChipData }; + return { + recordChipData: generateDefaultRecordChipData({ + objectNameSingular, + record, + }), + }; }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/RelationFromManyFieldDisplay.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/RelationFromManyFieldDisplay.tsx index f238be043..337e2b445 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/RelationFromManyFieldDisplay.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/RelationFromManyFieldDisplay.tsx @@ -6,7 +6,7 @@ import { RecordChip } from '@/object-record/components/RecordChip'; import { useFieldFocus } from '@/object-record/record-field/hooks/useFieldFocus'; import { useRelationFromManyFieldDisplay } from '@/object-record/record-field/meta-types/hooks/useRelationFromManyFieldDisplay'; import { ExpandableList } from '@/ui/layout/expandable-list/components/ExpandableList'; -import { isNull } from '@sniptt/guards'; +import { isDefined } from 'twenty-shared'; export const RelationFromManyFieldDisplay = () => { const { fieldValue, fieldDefinition } = useRelationFromManyFieldDisplay(); @@ -48,43 +48,37 @@ export const RelationFromManyFieldDisplay = () => { return ( - {fieldValue - .filter((record) => !isNull(record[relationFieldName])) - .map((record) => ( - - ))} + {fieldValue.filter(isDefined).map((record) => ( + + ))} ); } else if (isRelationFromActivityTargets) { return ( - {activityTargetObjectRecords - .filter((record) => !isNull(record.targetObject)) - .map((record) => ( - - ))} + {activityTargetObjectRecords.filter(isDefined).map((record) => ( + + ))} ); } else { return ( - {fieldValue - .filter((record) => !isNull(record)) - .map((record) => ( - - ))} + {fieldValue.filter(isDefined).map((record) => ( + + ))} ); } diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useRelationFromManyFieldDisplay.ts b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useRelationFromManyFieldDisplay.ts index 86c389d0b..42f851ff1 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useRelationFromManyFieldDisplay.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useRelationFromManyFieldDisplay.ts @@ -44,14 +44,26 @@ export const useRelationFromManyFieldDisplay = () => { ? maxWidth - FIELD_EDIT_BUTTON_WIDTH : maxWidth; - if (!isNonEmptyString(fieldDefinition.metadata.objectMetadataNameSingular)) { + if ( + !isDefined(fieldDefinition.metadata.objectMetadataNameSingular) || + !isNonEmptyString(fieldDefinition.metadata.objectMetadataNameSingular) + ) { throw new Error('Object metadata name singular is not a non-empty string'); } - const generateRecordChipData = + const fieldChipGenerator = chipGeneratorPerObjectPerField[ fieldDefinition.metadata.objectMetadataNameSingular - ]?.[fieldDefinition.metadata.fieldName] ?? generateDefaultRecordChipData; + ]?.[fieldDefinition.metadata.fieldName]; + const generateRecordChipData = isDefined(fieldChipGenerator) + ? fieldChipGenerator + : (record: ObjectRecord) => + generateDefaultRecordChipData({ + record, + // @ts-expect-error Above assertions does not infer that fieldDefinition.metadata.objectMetadataNameSingular always defined + objectNameSingular: + fieldDefinition.metadata.objectMetadataNameSingular, + }); return { fieldDefinition, diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useRelationToOneFieldDisplay.ts b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useRelationToOneFieldDisplay.ts index 3ad94bb3c..d40fb7a28 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useRelationToOneFieldDisplay.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useRelationToOneFieldDisplay.ts @@ -2,13 +2,13 @@ import { isNonEmptyString } from '@sniptt/guards'; import { useContext } from 'react'; import { PreComputedChipGeneratorsContext } from '@/object-metadata/contexts/PreComputedChipGeneratorsContext'; -import { generateDefaultRecordChipData } from '@/object-metadata/utils/generateDefaultRecordChipData'; import { useRecordFieldValue } from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext'; import { ObjectRecord } from '@/object-record/types/ObjectRecord'; import { FIELD_EDIT_BUTTON_WIDTH } from '@/ui/field/display/constants/FieldEditButtonWidth'; import { isDefined } from 'twenty-shared'; import { FieldMetadataType } from '~/generated-metadata/graphql'; +import { generateDefaultRecordChipData } from '@/object-metadata/utils/generateDefaultRecordChipData'; import { FieldContext } from '../../contexts/FieldContext'; import { assertFieldMetadata } from '../../types/guards/assertFieldMetadata'; import { isFieldRelation } from '../../types/guards/isFieldRelation'; @@ -44,14 +44,26 @@ export const useRelationToOneFieldDisplay = () => { ? maxWidth - FIELD_EDIT_BUTTON_WIDTH : maxWidth; - if (!isNonEmptyString(fieldDefinition.metadata.objectMetadataNameSingular)) { + if ( + !isDefined(fieldDefinition.metadata.objectMetadataNameSingular) || + !isNonEmptyString(fieldDefinition.metadata.objectMetadataNameSingular) + ) { throw new Error('Object metadata name singular is not a non-empty string'); } - const generateRecordChipData = + const fieldChipGenerator = chipGeneratorPerObjectPerField[ fieldDefinition.metadata.objectMetadataNameSingular - ]?.[fieldDefinition.metadata.fieldName] ?? generateDefaultRecordChipData; + ]?.[fieldDefinition.metadata.fieldName]; + const generateRecordChipData = isDefined(fieldChipGenerator) + ? fieldChipGenerator + : (record: ObjectRecord) => + generateDefaultRecordChipData({ + record, + // @ts-expect-error Above assertions does not infer that fieldDefinition.metadata.objectMetadataNameSingular always defined + objectNameSingular: + fieldDefinition.metadata.objectMetadataNameSingular, + }); return { fieldDefinition,