From 36452ecc8b62c9d57e5abc4a9dd4f87bf9b24476 Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Tue, 5 May 2026 14:54:49 +0200 Subject: [PATCH] fix: show 'Not shared' for RLS-hidden morph relation records (#20272) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Follow-up to #20260. The `MorphRelationManyToOneFieldDisplay` component (used for polymorphic MANY_TO_ONE relations) was missing the FK-presence check that `RelationToOneFieldDisplay` already has. When RLS hides a related record (e.g., a Rocket with a policy filtering by name), the API response contains a populated FK (`polymorphicOwnerRocketId`) but a `null` relation object. The component was rendering an empty cell instead of the "Not shared" lock icon. **Fix:** - In `useMorphRelationToOneFieldDisplay`, read the record from the store and check if any morph relation FK field is populated while the relation value is null - In `MorphRelationManyToOneFieldDisplay`, render `` when that condition is true | Scenario | FK in response | Relation object | Frontend display | |----------|---------------|-----------------|-----------------| | Live record | "abc" | `{ id: "abc", ... }` | Record chip | | Soft-deleted record | null | null | Empty cell | | RLS-hidden record | "abc" | null | "Not shared" | ## Test plan - Create a polymorphic MANY_TO_ONE relation (e.g., Pet → Rocket) - Add an RLS policy on the target object (e.g., Rocket name contains "Starship") - Verify the morph relation field shows "Not shared" (lock icon) for RLS-hidden records - Verify live records still display normally as record chips - Verify soft-deleted records still display as empty cells --- .../MorphRelationManyToOneFieldDisplay.tsx | 9 ++- .../useMorphRelationToOneFieldDisplay.ts | 13 +-- .../record-field/ui/types/FieldMetadata.ts | 13 +++ .../recordStoreFieldValueSelector.ts | 80 +++++++++---------- 4 files changed, 67 insertions(+), 48 deletions(-) diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/display/components/MorphRelationManyToOneFieldDisplay.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/display/components/MorphRelationManyToOneFieldDisplay.tsx index b37a602ac6..726c11eda3 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/display/components/MorphRelationManyToOneFieldDisplay.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/display/components/MorphRelationManyToOneFieldDisplay.tsx @@ -1,17 +1,22 @@ -import { CoreObjectNameSingular } from 'twenty-shared/types'; import { RecordChip } from '@/object-record/components/RecordChip'; import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext'; +import { ForbiddenFieldDisplay } from '@/object-record/record-field/ui/meta-types/display/components/ForbiddenFieldDisplay'; import { useMorphRelationToOneFieldDisplay } from '@/object-record/record-field/ui/meta-types/hooks/useMorphRelationToOneFieldDisplay'; import { useContext } from 'react'; +import { CoreObjectNameSingular } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; export const MorphRelationManyToOneFieldDisplay = () => { - const { morphFieldValuesWithObjectName } = + const { morphFieldValuesWithObjectName, foreignKeyFieldValue } = useMorphRelationToOneFieldDisplay(); const { disableChipClick, triggerEvent } = useContext(FieldContext); if (!isDefined(morphFieldValuesWithObjectName?.value)) { + if (isDefined(foreignKeyFieldValue)) { + return ; + } + return null; } diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/hooks/useMorphRelationToOneFieldDisplay.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/hooks/useMorphRelationToOneFieldDisplay.ts index 99134159eb..27f6eab7f5 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/hooks/useMorphRelationToOneFieldDisplay.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/hooks/useMorphRelationToOneFieldDisplay.ts @@ -13,7 +13,7 @@ import { isFieldMorphRelation } from '@/object-record/record-field/ui/types/guar import { useRecordFieldValue } from '@/object-record/record-store/hooks/useRecordFieldValue'; -import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { type FieldMorphRelationManyToOneValue } from '@/object-record/record-field/ui/types/FieldMetadata'; import { isDefined } from 'twenty-shared/utils'; export const useMorphRelationToOneFieldDisplay = () => { @@ -34,10 +34,12 @@ export const useMorphRelationToOneFieldDisplay = () => { const button = fieldDefinition.editButtonIcon; - const morphFieldValueWithObjectName = useRecordFieldValue<{ - objectNameSingular: string; - value: ObjectRecord; - }>(recordId, fieldDefinition.metadata.fieldName, fieldDefinition); + const morphFieldValueWithObjectName = + useRecordFieldValue( + recordId, + fieldDefinition.metadata.fieldName, + fieldDefinition, + ); if (!isDefined(morphFieldValueWithObjectName)) { return { @@ -66,6 +68,7 @@ export const useMorphRelationToOneFieldDisplay = () => { return { fieldDefinition, + foreignKeyFieldValue: morphFieldValueWithObjectName.foreignKeyFieldValue, morphFieldValuesWithObjectName: morphFieldValueWithObjectName, maxWidth: maxWidthForField, recordId, diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/types/FieldMetadata.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/types/FieldMetadata.ts index bacd2a2b78..2971d49228 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/types/FieldMetadata.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/types/FieldMetadata.ts @@ -276,6 +276,19 @@ export type FieldRelationValue< T extends FieldRelationToOneValue | FieldRelationFromManyValue, > = T; +export type FieldMorphRelationManyToOneValue = { + objectNameSingular: string; + objectNamePlural: string; + value?: ObjectRecord; + foreignKeyFieldValue: string; +} | null; + +export type FieldMorphRelationOneToManyValue = { + objectNameSingular: string; + objectNamePlural: string; + value: ObjectRecord[]; +}[]; + export type Json = ZodHelperLiteral | { [key: string]: Json } | Json[]; export type FieldJsonValue = Record | Json[] | null; diff --git a/packages/twenty-front/src/modules/object-record/record-store/states/selectors/recordStoreFieldValueSelector.ts b/packages/twenty-front/src/modules/object-record/record-store/states/selectors/recordStoreFieldValueSelector.ts index daf9912699..b97f4e20eb 100644 --- a/packages/twenty-front/src/modules/object-record/record-store/states/selectors/recordStoreFieldValueSelector.ts +++ b/packages/twenty-front/src/modules/object-record/record-store/states/selectors/recordStoreFieldValueSelector.ts @@ -1,11 +1,17 @@ import { atom, type Atom } from 'jotai'; +import { type FieldMetadataItemRelation } from '@/object-metadata/types/FieldMetadataItemRelation'; import { type FieldDefinition } from '@/object-record/record-field/ui/types/FieldDefinition'; -import { type FieldMetadata } from '@/object-record/record-field/ui/types/FieldMetadata'; +import { + type FieldMetadata, + type FieldMorphRelationManyToOneValue, + type FieldMorphRelationMetadata, + type FieldMorphRelationOneToManyValue, +} from '@/object-record/record-field/ui/types/FieldMetadata'; import { isFieldMorphRelation } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelation'; import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; import { createAtomFamilySelector } from '@/ui/utilities/state/jotai/utils/createAtomFamilySelector'; -import { RelationType, type ObjectRecord } from 'twenty-shared/types'; +import { RelationType } from 'twenty-shared/types'; import { computeMorphRelationFieldName, isDefined } from 'twenty-shared/utils'; const simpleFieldValueSelector = createAtomFamilySelector< @@ -34,61 +40,53 @@ const getMorphRelationFieldValueAtom = ( } const morphRelations = - ( - fieldDefinition.metadata as { - morphRelations?: Array<{ - type: string; - sourceFieldMetadata: { name: string }; - targetObjectMetadata: { - nameSingular: string; - namePlural: string; - }; - }>; - } - ).morphRelations ?? []; + (fieldDefinition.metadata as FieldMorphRelationMetadata).morphRelations ?? + []; + + const relationType = morphRelations[0]?.type; const derivedAtom = atom((get) => { - const record = get(recordStoreFamilyState.atomFamily(recordId)); + const recordStore = get(recordStoreFamilyState.atomFamily(recordId)); - const morphValuesWithObjectName = morphRelations.map((morphRelation) => { - const computedFieldName = computeMorphRelationFieldName({ + const computeMorphFieldName = (morphRelation: FieldMetadataItemRelation) => + computeMorphRelationFieldName({ fieldName: morphRelation.sourceFieldMetadata.name, - relationType: morphRelation.type as RelationType, + relationType: morphRelation.type, targetObjectMetadataNameSingular: morphRelation.targetObjectMetadata.nameSingular, targetObjectMetadataNamePlural: morphRelation.targetObjectMetadata.namePlural, }); - return { - objectNameSingular: morphRelation.targetObjectMetadata.nameSingular, - value: record?.[computedFieldName], - }; - }); - - const relationType = morphRelations[0]?.type; - if (relationType === RelationType.ONE_TO_MANY) { - return morphValuesWithObjectName.map((morphValue) => ({ - ...morphValue, - value: morphValue.value ? morphValue.value : [], - })) as { - objectNameSingular: string; - value: ObjectRecord[]; - }[]; + return morphRelations.map((morphRelation) => ({ + objectNameSingular: morphRelation.targetObjectMetadata.nameSingular, + objectNamePlural: morphRelation.targetObjectMetadata.namePlural, + value: recordStore?.[computeMorphFieldName(morphRelation)] ?? [], + })) as FieldMorphRelationOneToManyValue; } if (relationType === RelationType.MANY_TO_ONE) { - const morphValueFiltered = morphValuesWithObjectName.filter( - (morphValue) => isDefined(morphValue.value), + const morphValuesWithObjectName = morphRelations.map((morphRelation) => { + const computedFieldName = computeMorphFieldName(morphRelation); + + return { + objectNameSingular: morphRelation.targetObjectMetadata.nameSingular, + objectNamePlural: morphRelation.targetObjectMetadata.namePlural, + value: recordStore?.[computedFieldName], + foreignKeyFieldValue: recordStore?.[`${computedFieldName}Id`], + }; + }); + + const morphValueWithRelationOrForeignKey = morphValuesWithObjectName.find( + (morphValue) => isDefined(morphValue.foreignKeyFieldValue), ); - return morphValueFiltered.length > 0 - ? (morphValueFiltered[0] as { - objectNameSingular: string; - value: ObjectRecord; - }) - : null; + if (isDefined(morphValueWithRelationOrForeignKey)) { + return morphValueWithRelationOrForeignKey as FieldMorphRelationManyToOneValue; + } + + return null; } return null;