fix(page-layout): render relation field widgets in table display mode (#22220)

Adding a to-many relation field as a **Table** on a record page rendered
an empty widget (header only) in several cases. This fixes three
independent defects behind that.

- **Morph inverse relations crashed the table.** The host-scoping view
filter (`IS current record`) is built on the relation's inverse field.
When that inverse is a `MORPH_RELATION` (attachments, notes, tasks…),
`getFilterTypeFromFieldType` fell through to `TEXT` and the GraphQL
builder threw `Unknown operand IS for TEXT filter`, unmounting the table
via the ErrorBoundary. `MORPH_RELATION` now classifies as `RELATION`,
and the relation filter resolves the correct morph join column (e.g.
`targetPersonId`) from the current record's object type.
- **Stale `viewId` on field change.** Changing the bound field on a
Table widget kept the previous relation's draft view (wrong
object/fields/filter). Field selection now regenerates the draft view
for the new relation, or clears the stale `viewId` when the new field
can't back a table.
- **Label identifier could be hidden or reordered.** Relation-table
widget views now pin the label-identifier field first and visible on
view creation and save.

Deferred: morph relation filters with arbitrary selected record ids (not
just "current record") — needs target-object identity in the filter
value schema.

**Test:** open a Person → edit layout → add a Field widget → bind a
to-many relation → switch Layout to Table. Previously empty for
`attachments` (morph) and for any field changed on an existing Table
widget; now scoped to the host record.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22220?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
nitin
2026-06-30 19:03:04 +05:30
committed by GitHub
parent 46ef8a8813
commit 5a5c829129
15 changed files with 459 additions and 27 deletions
@@ -1,5 +1,6 @@
import {
FieldMetadataType,
RelationType,
ViewFilterOperand as RecordFilterOperand,
} from '@/types';
import { type RecordFilter } from '@/utils';
@@ -55,6 +56,28 @@ const fields = [
type: FieldMetadataType.RELATION,
label: 'Account Owner',
},
{
id: 'f-morph-relation',
name: 'target',
type: FieldMetadataType.MORPH_RELATION,
label: 'Target',
morphRelations: [
{
type: RelationType.MANY_TO_ONE,
targetObjectMetadata: {
nameSingular: 'person',
namePlural: 'people',
},
},
{
type: RelationType.MANY_TO_ONE,
targetObjectMetadata: {
nameSingular: 'company',
namePlural: 'companies',
},
},
],
},
{
id: 'f-bool',
name: 'isActive',
@@ -614,6 +637,67 @@ describe('turnRecordFilterIntoRecordGqlOperationFilter', () => {
expect(result).toHaveProperty('or');
});
it('should resolve programmatic current-record morph relation-table filters to the matching join column', () => {
const result = turnRecordFilterIntoRecordGqlOperationFilter({
filterValueDependencies: {
...filterValueDependencies,
currentRecord: {
id: '11111111-1111-4111-8111-111111111111',
objectMetadataNameSingular: 'person',
},
},
recordFilter: makeFilter(
'f-morph-relation',
RecordFilterOperand.IS,
JSON.stringify({
selectedRecordIds: [],
isCurrentRecordSelected: true,
}),
'RELATION',
),
fieldMetadataItemById,
});
expect(result).toEqual({
targetPersonId: { in: ['11111111-1111-4111-8111-111111111111'] },
});
});
it('should omit programmatic current-record morph filters when morph relation metadata is missing', () => {
const fieldMetadataItemByIdWithoutMorphRelations = new Map(
fieldMetadataItemById,
);
fieldMetadataItemByIdWithoutMorphRelations.set('f-morph-relation', {
id: 'f-morph-relation',
name: 'target',
type: FieldMetadataType.MORPH_RELATION,
label: 'Target',
});
const result = turnRecordFilterIntoRecordGqlOperationFilter({
filterValueDependencies: {
...filterValueDependencies,
currentRecord: {
id: '11111111-1111-4111-8111-111111111111',
objectMetadataNameSingular: 'person',
},
},
recordFilter: makeFilter(
'f-morph-relation',
RecordFilterOperand.IS,
JSON.stringify({
selectedRecordIds: [],
isCurrentRecordSelected: true,
}),
'RELATION',
),
fieldMetadataItemById: fieldMetadataItemByIdWithoutMorphRelations,
});
expect(result).toBeUndefined();
});
});
describe('RAW_JSON filter', () => {
@@ -19,6 +19,7 @@ import {
type RawJsonFilter,
type RecordFilterValueDependencies,
type RecordGqlOperationFilter,
type RelationType,
type RelationFilter,
type SelectFilter,
type StringFilter,
@@ -54,12 +55,25 @@ import {
import { arrayOfStringsOrVariablesSchema } from '@/utils/filter/utils/validation-schemas/arrayOfStringsOrVariablesSchema';
import { arrayOfUuidOrVariableSchema } from '@/utils/filter/utils/validation-schemas/arrayOfUuidsOrVariablesSchema';
import { jsonRelationFilterValueSchema } from '@/utils/filter/utils/validation-schemas/jsonRelationFilterValueSchema';
import {
computeMorphRelationGqlFieldJoinColumnName,
computeRelationGqlFieldJoinColumnName,
} from '@/utils/fieldMetadata/compute-relation-gql-field-join-column-name';
type FieldSharedMorphRelation = {
type: RelationType;
targetObjectMetadata: {
nameSingular: string;
namePlural: string;
};
};
export type FieldShared = {
id: string;
name: string;
type: FieldMetadataType;
label: string;
morphRelations?: FieldSharedMorphRelation[] | null;
};
type TurnRecordFilterIntoRecordGqlOperationFilterParams = {
@@ -131,6 +145,39 @@ type BuildDirectFieldGqlOperationFilterParams = {
fieldMetadataItem: FieldShared;
};
const getRelationFilterJoinColumnName = ({
fieldMetadataItem,
filterValueDependencies,
}: {
fieldMetadataItem: FieldShared;
filterValueDependencies: RecordFilterValueDependencies;
}): string | undefined => {
if (fieldMetadataItem.type !== FieldMetadataType.MORPH_RELATION) {
return computeRelationGqlFieldJoinColumnName({
name: fieldMetadataItem.name,
});
}
const matchingMorphRelation = fieldMetadataItem.morphRelations?.find(
(morphRelation) =>
morphRelation.targetObjectMetadata.nameSingular ===
filterValueDependencies.currentRecord?.objectMetadataNameSingular,
);
if (!isDefined(matchingMorphRelation)) {
return;
}
return computeMorphRelationGqlFieldJoinColumnName({
fieldName: fieldMetadataItem.name,
relationType: matchingMorphRelation.type,
targetObjectMetadataNameSingular:
matchingMorphRelation.targetObjectMetadata.nameSingular,
targetObjectMetadataNamePlural:
matchingMorphRelation.targetObjectMetadata.namePlural,
});
};
const buildDirectFieldGqlOperationFilter = ({
recordFilter,
fieldMetadataItem,
@@ -590,16 +637,25 @@ const buildDirectFieldGqlOperationFilter = ({
? [filterValueDependencies?.currentWorkspaceMemberId]
: []),
...(isCurrentRecordSelected
? [filterValueDependencies?.currentRecordId]
? [filterValueDependencies.currentRecord?.id]
: []),
].filter(isDefined);
if (recordIds.length === 0) return;
const relationFilterJoinColumnName = getRelationFilterJoinColumnName({
fieldMetadataItem,
filterValueDependencies,
});
if (!isDefined(relationFilterJoinColumnName)) {
return;
}
switch (recordFilter.operand) {
case RecordFilterOperand.IS:
return {
[fieldMetadataItem.name + 'Id']: {
[relationFilterJoinColumnName]: {
in: recordIds,
} as RelationFilter,
};
@@ -609,13 +665,13 @@ const buildDirectFieldGqlOperationFilter = ({
or: [
{
not: {
[fieldMetadataItem.name + 'Id']: {
[relationFilterJoinColumnName]: {
in: recordIds,
} as RelationFilter,
},
},
{
[fieldMetadataItem.name + 'Id']: {
[relationFilterJoinColumnName]: {
is: 'NULL',
} as RelationFilter,
},
@@ -24,6 +24,7 @@ export const getFilterTypeFromFieldType = (
case FieldMetadataType.PHONES:
return 'PHONES';
case FieldMetadataType.RELATION:
case FieldMetadataType.MORPH_RELATION:
return 'RELATION';
case FieldMetadataType.SELECT:
return 'SELECT';