From 5a5c829129b2d9a36d940e5f86ffafaab89f3f0c Mon Sep 17 00:00:00 2001
From: nitin <142569587+ehconitin@users.noreply.github.com>
Date: Tue, 30 Jun 2026 19:03:04 +0530
Subject: [PATCH] fix(page-layout): render relation field widgets in table
display mode (#22220)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
.../RecordFilterValueDependenciesContext.ts | 8 +-
.../hooks/useFilterValueDependencies.ts | 6 +-
.../hooks/useBuildRecordInputFromFilters.ts | 4 +-
.../hooks/useSaveRecordTableWidgetViews.ts | 40 ++++++++-
.../components/FieldWidgetRelationTable.tsx | 15 +++-
...lveFieldWidgetRelationTableViewIdChange.ts | 68 +++++++++++++++
...buildRecordTableWidgetViewSnapshot.test.ts | 24 ++++++
...rmalizeRecordTableWidgetViewFields.test.ts | 75 +++++++++++++++++
.../buildRecordTableWidgetViewSnapshot.ts | 23 +++--
.../normalizeRecordTableWidgetViewFields.ts | 50 +++++++++++
.../FieldWidgetFieldDropdownContent.tsx | 19 ++++-
.../types/RecordFilterValueDependencies.ts | 5 +-
...RecordFilterIntoGqlOperationFilter.test.ts | 84 +++++++++++++++++++
.../turnRecordFilterIntoGqlOperationFilter.ts | 64 +++++++++++++-
.../utils/getFilterTypeFromFieldType.ts | 1 +
15 files changed, 459 insertions(+), 27 deletions(-)
create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/record-table/hooks/useResolveFieldWidgetRelationTableViewIdChange.ts
create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/__tests__/normalizeRecordTableWidgetViewFields.test.ts
create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/normalizeRecordTableWidgetViewFields.ts
diff --git a/packages/twenty-front/src/modules/object-record/record-filter/contexts/RecordFilterValueDependenciesContext.ts b/packages/twenty-front/src/modules/object-record/record-filter/contexts/RecordFilterValueDependenciesContext.ts
index 3a83718793..43ad315a57 100644
--- a/packages/twenty-front/src/modules/object-record/record-filter/contexts/RecordFilterValueDependenciesContext.ts
+++ b/packages/twenty-front/src/modules/object-record/record-filter/contexts/RecordFilterValueDependenciesContext.ts
@@ -1,8 +1,10 @@
import { createContext } from 'react';
+import { type RecordFilterValueDependencies } from 'twenty-shared/types';
-export type RecordFilterValueDependenciesContextValue = {
- currentRecordId?: string;
-};
+export type RecordFilterValueDependenciesContextValue = Pick<
+ RecordFilterValueDependencies,
+ 'currentRecord'
+>;
export const RecordFilterValueDependenciesContext =
createContext({});
diff --git a/packages/twenty-front/src/modules/object-record/record-filter/hooks/useFilterValueDependencies.ts b/packages/twenty-front/src/modules/object-record/record-filter/hooks/useFilterValueDependencies.ts
index 25c39a0d40..359b8b933b 100644
--- a/packages/twenty-front/src/modules/object-record/record-filter/hooks/useFilterValueDependencies.ts
+++ b/packages/twenty-front/src/modules/object-record/record-filter/hooks/useFilterValueDependencies.ts
@@ -14,15 +14,15 @@ export const useFilterValueDependencies = (): {
const { userTimezone } = useUserTimezone();
- const { currentRecordId } = useContext(RecordFilterValueDependenciesContext);
+ const { currentRecord } = useContext(RecordFilterValueDependenciesContext);
const filterValueDependencies = useMemo(
() => ({
currentWorkspaceMemberId,
- currentRecordId,
+ currentRecord,
timeZone: userTimezone,
}),
- [currentWorkspaceMemberId, currentRecordId, userTimezone],
+ [currentWorkspaceMemberId, currentRecord, userTimezone],
);
return { filterValueDependencies };
diff --git a/packages/twenty-front/src/modules/object-record/record-table/hooks/useBuildRecordInputFromFilters.ts b/packages/twenty-front/src/modules/object-record/record-table/hooks/useBuildRecordInputFromFilters.ts
index bc50630dc2..68ebb57253 100644
--- a/packages/twenty-front/src/modules/object-record/record-table/hooks/useBuildRecordInputFromFilters.ts
+++ b/packages/twenty-front/src/modules/object-record/record-table/hooks/useBuildRecordInputFromFilters.ts
@@ -24,7 +24,7 @@ export const useBuildRecordInputFromFilters = ({
const currentWorkspaceMember = useAtomStateValue(currentWorkspaceMemberState);
- const { currentRecordId } = useContext(RecordFilterValueDependenciesContext);
+ const { currentRecord } = useContext(RecordFilterValueDependenciesContext);
const { userTimezone } = useUserTimezone();
@@ -33,7 +33,7 @@ export const useBuildRecordInputFromFilters = ({
currentRecordFilters,
objectMetadataItem,
currentWorkspaceMember: currentWorkspaceMember ?? undefined,
- currentRecordId,
+ currentRecordId: currentRecord?.id,
timeZone: userTimezone,
});
};
diff --git a/packages/twenty-front/src/modules/page-layout/hooks/useSaveRecordTableWidgetViews.ts b/packages/twenty-front/src/modules/page-layout/hooks/useSaveRecordTableWidgetViews.ts
index f44c59aa68..d692de90c4 100644
--- a/packages/twenty-front/src/modules/page-layout/hooks/useSaveRecordTableWidgetViews.ts
+++ b/packages/twenty-front/src/modules/page-layout/hooks/useSaveRecordTableWidgetViews.ts
@@ -1,3 +1,4 @@
+import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector';
import { UPSERT_VIEW_WIDGET } from '@/page-layout/graphql/mutations/upsertViewWidget';
import { useHasRecordTableWidgetViewChanges } from '@/page-layout/hooks/useHasRecordTableWidgetViewChanges';
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
@@ -5,6 +6,7 @@ import { recordTableWidgetViewDraftComponentState } from '@/page-layout/states/r
import { recordTableWidgetViewPersistedComponentState } from '@/page-layout/states/recordTableWidgetViewPersistedComponentState';
import { getWidgetConfigurationViewId } from '@/page-layout/utils/getWidgetConfigurationViewId';
import { widgetUsesRecordTableView } from '@/page-layout/utils/widgetUsesRecordTableView';
+import { normalizeRecordTableWidgetViewFields } from '@/page-layout/widgets/record-table/utils/normalizeRecordTableWidgetViewFields';
import { useMutation } from '@apollo/client/react';
import { useStore } from 'jotai';
import { useCallback } from 'react';
@@ -47,6 +49,10 @@ export const useSaveRecordTableWidgetViews = () => {
.flatMap((tab) => tab.widgets)
.filter(widgetUsesRecordTableView);
+ const objectMetadataItems = store.get(objectMetadataItemsSelector.atom);
+
+ let normalizedRecordTableWidgetViewDraft = recordTableWidgetViewDraft;
+
for (const widget of draftRecordTableWidgets) {
const viewId = getWidgetConfigurationViewId(widget.configuration);
@@ -60,11 +66,34 @@ export const useSaveRecordTableWidgetViews = () => {
continue;
}
+ const objectMetadataItem = objectMetadataItems.find(
+ (objectMetadataItem) =>
+ objectMetadataItem.id === widgetViewDraft.view.objectMetadataId,
+ );
+
+ const normalizedViewFields = isDefined(objectMetadataItem)
+ ? normalizeRecordTableWidgetViewFields({
+ viewFields: widgetViewDraft.viewFields,
+ labelIdentifierFieldMetadataId:
+ objectMetadataItem.labelIdentifierFieldMetadataId,
+ })
+ : widgetViewDraft.viewFields;
+
+ const normalizedWidgetViewDraft = {
+ ...widgetViewDraft,
+ viewFields: normalizedViewFields,
+ };
+
+ normalizedRecordTableWidgetViewDraft = {
+ ...normalizedRecordTableWidgetViewDraft,
+ [widget.id]: normalizedWidgetViewDraft,
+ };
+
await upsertViewWidgetMutation({
variables: {
input: {
widgetId: widget.id,
- viewFields: widgetViewDraft.viewFields.map((field) => ({
+ viewFields: normalizedWidgetViewDraft.viewFields.map((field) => ({
fieldMetadataId: field.fieldMetadataId,
isVisible: field.isVisible,
position: field.position,
@@ -100,11 +129,18 @@ export const useSaveRecordTableWidgetViews = () => {
});
}
+ store.set(
+ recordTableWidgetViewDraftComponentState.atomFamily({
+ instanceId: pageLayoutId,
+ }),
+ normalizedRecordTableWidgetViewDraft,
+ );
+
store.set(
recordTableWidgetViewPersistedComponentState.atomFamily({
instanceId: pageLayoutId,
}),
- recordTableWidgetViewDraft,
+ normalizedRecordTableWidgetViewDraft,
);
},
[hasRecordTableWidgetViewChanges, store, upsertViewWidgetMutation],
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetRelationTable.tsx b/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetRelationTable.tsx
index d3ef55693e..4053ab7fcf 100644
--- a/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetRelationTable.tsx
+++ b/packages/twenty-front/src/modules/page-layout/widgets/field/components/FieldWidgetRelationTable.tsx
@@ -41,14 +41,25 @@ export const FieldWidgetRelationTable = ({
const relationObjectMetadataId =
fieldDefinition.metadata.relationObjectMetadataId;
+ const recordPageObjectMetadataNameSingular =
+ fieldDefinition.metadata.objectMetadataNameSingular;
- if (!isDefined(viewId) || !isDefined(relationObjectMetadataId)) {
+ if (
+ !isDefined(viewId) ||
+ !isDefined(relationObjectMetadataId) ||
+ !isDefined(recordPageObjectMetadataNameSingular)
+ ) {
return null;
}
return (
{
+ const { addDraftViewForFieldRelationTableWidget } =
+ useAddDraftViewForFieldRelationTableWidget(pageLayoutId);
+
+ const resolveFieldWidgetRelationTableViewIdChange = ({
+ selectedField,
+ currentDisplayMode,
+ isSelectingDifferentField,
+ widgetId,
+ currentViewId,
+ }: ResolveFieldWidgetRelationTableViewIdChangeArgs):
+ | Pick
+ | undefined => {
+ const targetObjectMetadataId =
+ selectedField?.relation?.targetObjectMetadata.id;
+ const targetFieldMetadataId =
+ selectedField?.relation?.targetFieldMetadata.id;
+
+ const shouldRegenerateRelationTableView =
+ currentDisplayMode === FieldDisplayMode.TABLE &&
+ isSelectingDifferentField &&
+ selectedField?.type === FieldMetadataType.RELATION &&
+ selectedField.relation?.type === RelationType.ONE_TO_MANY &&
+ isDefined(widgetId) &&
+ isDefined(targetObjectMetadataId) &&
+ isDefined(targetFieldMetadataId);
+
+ const regeneratedRelationTableViewId = shouldRegenerateRelationTableView
+ ? addDraftViewForFieldRelationTableWidget(
+ widgetId,
+ targetObjectMetadataId,
+ targetFieldMetadataId,
+ )
+ : undefined;
+
+ if (isDefined(regeneratedRelationTableViewId)) {
+ return { viewId: regeneratedRelationTableViewId };
+ }
+
+ if (isSelectingDifferentField && isDefined(currentViewId)) {
+ return { viewId: undefined };
+ }
+
+ return undefined;
+ };
+
+ return { resolveFieldWidgetRelationTableViewIdChange };
+};
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/__tests__/buildRecordTableWidgetViewSnapshot.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/__tests__/buildRecordTableWidgetViewSnapshot.test.ts
index 365f698a3c..d5b76db6cd 100644
--- a/packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/__tests__/buildRecordTableWidgetViewSnapshot.test.ts
+++ b/packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/__tests__/buildRecordTableWidgetViewSnapshot.test.ts
@@ -10,4 +10,28 @@ describe('buildRecordTableWidgetViewSnapshot', () => {
expect(view.visibility).toBe(ViewVisibility.WORKSPACE);
});
+
+ it('should keep the label identifier view field visible and in the lowest position', () => {
+ const { viewFields } =
+ buildRecordTableWidgetViewSnapshot(objectMetadataItem);
+
+ const labelIdentifierViewField = viewFields.find(
+ (viewField) =>
+ viewField.fieldMetadataId ===
+ objectMetadataItem.labelIdentifierFieldMetadataId,
+ );
+
+ expect(labelIdentifierViewField).toMatchObject({
+ isVisible: true,
+ position: 0,
+ });
+ expect(
+ viewFields.every(
+ (viewField) =>
+ viewField.fieldMetadataId ===
+ objectMetadataItem.labelIdentifierFieldMetadataId ||
+ viewField.position > 0,
+ ),
+ ).toBe(true);
+ });
});
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/__tests__/normalizeRecordTableWidgetViewFields.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/__tests__/normalizeRecordTableWidgetViewFields.test.ts
new file mode 100644
index 0000000000..395abcf5fe
--- /dev/null
+++ b/packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/__tests__/normalizeRecordTableWidgetViewFields.test.ts
@@ -0,0 +1,75 @@
+import { normalizeRecordTableWidgetViewFields } from '@/page-layout/widgets/record-table/utils/normalizeRecordTableWidgetViewFields';
+
+describe('normalizeRecordTableWidgetViewFields', () => {
+ it('should keep the label identifier view field visible and before every other view field', () => {
+ const normalizedViewFields = normalizeRecordTableWidgetViewFields({
+ labelIdentifierFieldMetadataId: 'label-field-id',
+ viewFields: [
+ {
+ id: 'hidden-field-id',
+ fieldMetadataId: 'hidden-field-metadata-id',
+ isVisible: false,
+ position: 0,
+ size: 180,
+ },
+ {
+ id: 'label-view-field-id',
+ fieldMetadataId: 'label-field-id',
+ isVisible: false,
+ position: 4,
+ size: 180,
+ },
+ {
+ id: 'visible-field-id',
+ fieldMetadataId: 'visible-field-metadata-id',
+ isVisible: true,
+ position: 0,
+ size: 180,
+ },
+ ],
+ });
+
+ expect(normalizedViewFields).toEqual([
+ {
+ id: 'label-view-field-id',
+ fieldMetadataId: 'label-field-id',
+ isVisible: true,
+ position: 0,
+ size: 180,
+ },
+ {
+ id: 'hidden-field-id',
+ fieldMetadataId: 'hidden-field-metadata-id',
+ isVisible: false,
+ position: 1,
+ size: 180,
+ },
+ {
+ id: 'visible-field-id',
+ fieldMetadataId: 'visible-field-metadata-id',
+ isVisible: true,
+ position: 2,
+ size: 180,
+ },
+ ]);
+ });
+
+ it('should leave view fields unchanged when the label identifier view field is absent', () => {
+ const viewFields = [
+ {
+ id: 'field-id',
+ fieldMetadataId: 'field-metadata-id',
+ isVisible: false,
+ position: 0,
+ size: 180,
+ },
+ ];
+
+ expect(
+ normalizeRecordTableWidgetViewFields({
+ viewFields,
+ labelIdentifierFieldMetadataId: 'missing-label-field-id',
+ }),
+ ).toBe(viewFields);
+ });
+});
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/buildRecordTableWidgetViewSnapshot.ts b/packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/buildRecordTableWidgetViewSnapshot.ts
index 973e85c063..e438b33318 100644
--- a/packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/buildRecordTableWidgetViewSnapshot.ts
+++ b/packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/buildRecordTableWidgetViewSnapshot.ts
@@ -3,6 +3,7 @@ import { type FlatViewField } from '@/metadata-store/types/FlatViewField';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { type RecordTableWidgetViewSnapshot } from '@/page-layout/widgets/record-table/types/RecordTableWidgetViewSnapshot';
import { filterFieldsForRecordTableViewCreation } from '@/page-layout/widgets/record-table/utils/filterFieldsForRecordTableViewCreation';
+import { normalizeRecordTableWidgetViewFields } from '@/page-layout/widgets/record-table/utils/normalizeRecordTableWidgetViewFields';
import { sortFieldsByRelevanceForRecordTableWidget } from '@/page-layout/widgets/record-table/utils/sortFieldsByRelevanceForRecordTableWidget';
import { v4 } from 'uuid';
import {
@@ -46,15 +47,19 @@ export const buildRecordTableWidgetViewSnapshot = (
),
);
- const flatViewFields: FlatViewField[] = sortedFields.map((field, index) => ({
- id: v4(),
- viewId: newViewId,
- fieldMetadataId: field.id,
- position: index,
- size: DEFAULT_VIEW_FIELD_SIZE,
- isVisible: index < INITIAL_VISIBLE_FIELDS_COUNT_IN_WIDGET,
- isActive: true,
- }));
+ const flatViewFields: FlatViewField[] = normalizeRecordTableWidgetViewFields({
+ viewFields: sortedFields.map((field, index) => ({
+ id: v4(),
+ viewId: newViewId,
+ fieldMetadataId: field.id,
+ position: index,
+ size: DEFAULT_VIEW_FIELD_SIZE,
+ isVisible: index < INITIAL_VISIBLE_FIELDS_COUNT_IN_WIDGET,
+ isActive: true,
+ })),
+ labelIdentifierFieldMetadataId:
+ objectMetadataItem.labelIdentifierFieldMetadataId,
+ });
return {
view: flatView,
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/normalizeRecordTableWidgetViewFields.ts b/packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/normalizeRecordTableWidgetViewFields.ts
new file mode 100644
index 0000000000..565bb07906
--- /dev/null
+++ b/packages/twenty-front/src/modules/page-layout/widgets/record-table/utils/normalizeRecordTableWidgetViewFields.ts
@@ -0,0 +1,50 @@
+import { isDefined } from 'twenty-shared/utils';
+
+type RecordTableWidgetViewFieldWithLabelIdentifierInvariant = {
+ fieldMetadataId: string;
+ isVisible: boolean;
+ position: number;
+};
+
+type NormalizeRecordTableWidgetViewFieldsArgs<
+ ViewField extends RecordTableWidgetViewFieldWithLabelIdentifierInvariant,
+> = {
+ viewFields: ViewField[];
+ labelIdentifierFieldMetadataId: string;
+};
+
+export const normalizeRecordTableWidgetViewFields = <
+ ViewField extends RecordTableWidgetViewFieldWithLabelIdentifierInvariant,
+>({
+ viewFields,
+ labelIdentifierFieldMetadataId,
+}: NormalizeRecordTableWidgetViewFieldsArgs): ViewField[] => {
+ const labelIdentifierViewField = viewFields.find(
+ (viewField) => viewField.fieldMetadataId === labelIdentifierFieldMetadataId,
+ );
+
+ if (!isDefined(labelIdentifierViewField)) {
+ return viewFields;
+ }
+
+ const normalizedLabelIdentifierViewField: ViewField = {
+ ...labelIdentifierViewField,
+ isVisible: true,
+ position: 0,
+ };
+
+ const normalizedOtherViewFields = viewFields
+ .filter(
+ (viewField) =>
+ viewField.fieldMetadataId !== labelIdentifierFieldMetadataId,
+ )
+ .toSorted((viewFieldA, viewFieldB) => {
+ return viewFieldA.position - viewFieldB.position;
+ })
+ .map((viewField, index) => ({
+ ...viewField,
+ position: index + 1,
+ }));
+
+ return [normalizedLabelIdentifierViewField, ...normalizedOtherViewFields];
+};
diff --git a/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/dropdown-content/FieldWidgetFieldDropdownContent.tsx b/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/dropdown-content/FieldWidgetFieldDropdownContent.tsx
index a7f6ec35af..470649afcd 100644
--- a/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/dropdown-content/FieldWidgetFieldDropdownContent.tsx
+++ b/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/dropdown-content/FieldWidgetFieldDropdownContent.tsx
@@ -1,8 +1,8 @@
import { useFieldMetadataItemById } from '@/object-metadata/hooks/useFieldMetadataItemById';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { isAdvancedRelationFieldMetadataItem } from '@/object-record/utils/isAdvancedRelationFieldMetadataItem';
-import { isDefined } from 'twenty-shared/utils';
import { useUpdatePageLayoutWidget } from '@/page-layout/hooks/useUpdatePageLayoutWidget';
+import { useResolveFieldWidgetRelationTableViewIdChange } from '@/page-layout/widgets/record-table/hooks/useResolveFieldWidgetRelationTableViewIdChange';
import { useFieldWidgetEligibleFields } from '@/page-layout/widgets/field/hooks/useFieldWidgetEligibleFields';
import {
getFieldWidgetDefaultDisplayMode,
@@ -26,6 +26,7 @@ import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/com
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { t } from '@lingui/core/macro';
import { useMemo, useState } from 'react';
+import { isDefined } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/icon';
import { MenuItemSelect } from 'twenty-ui/navigation';
import { type FieldConfiguration } from '~/generated-metadata/graphql';
@@ -92,6 +93,9 @@ export const FieldWidgetFieldDropdownContent = () => {
const { updatePageLayoutWidget } = useUpdatePageLayoutWidget(pageLayoutId);
+ const { resolveFieldWidgetRelationTableViewIdChange } =
+ useResolveFieldWidgetRelationTableViewIdChange(pageLayoutId);
+
const { closeDropdown } = useCloseDropdown();
const { getIcon } = useIcons();
@@ -126,9 +130,22 @@ export const FieldWidgetFieldDropdownContent = () => {
selectedField.relation?.type,
);
+ const isSelectingDifferentField =
+ currentFieldMetadataId !== fieldMetadataId;
+
+ const relationTableViewIdChange =
+ resolveFieldWidgetRelationTableViewIdChange({
+ selectedField,
+ currentDisplayMode,
+ isSelectingDifferentField,
+ widgetId: widgetInEditMode?.id,
+ currentViewId: fieldConfiguration?.viewId,
+ });
+
updateCurrentWidgetConfig({
configToUpdate: {
fieldMetadataId,
+ ...relationTableViewIdChange,
...(needsDisplayModeSwitch && {
fieldDisplayMode: getFieldWidgetDefaultDisplayMode(
selectedField.type,
diff --git a/packages/twenty-shared/src/types/RecordFilterValueDependencies.ts b/packages/twenty-shared/src/types/RecordFilterValueDependencies.ts
index 02642ec08b..0208b278ff 100644
--- a/packages/twenty-shared/src/types/RecordFilterValueDependencies.ts
+++ b/packages/twenty-shared/src/types/RecordFilterValueDependencies.ts
@@ -1,5 +1,8 @@
export interface RecordFilterValueDependencies {
currentWorkspaceMemberId?: string;
- currentRecordId?: string;
+ currentRecord?: {
+ id: string;
+ objectMetadataNameSingular: string;
+ };
timeZone?: string;
}
diff --git a/packages/twenty-shared/src/utils/filter/__tests__/turnRecordFilterIntoGqlOperationFilter.test.ts b/packages/twenty-shared/src/utils/filter/__tests__/turnRecordFilterIntoGqlOperationFilter.test.ts
index 95d70bf481..1c40f1b45e 100644
--- a/packages/twenty-shared/src/utils/filter/__tests__/turnRecordFilterIntoGqlOperationFilter.test.ts
+++ b/packages/twenty-shared/src/utils/filter/__tests__/turnRecordFilterIntoGqlOperationFilter.test.ts
@@ -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', () => {
diff --git a/packages/twenty-shared/src/utils/filter/turnRecordFilterIntoGqlOperationFilter.ts b/packages/twenty-shared/src/utils/filter/turnRecordFilterIntoGqlOperationFilter.ts
index 14ee26ddd4..9dca576731 100644
--- a/packages/twenty-shared/src/utils/filter/turnRecordFilterIntoGqlOperationFilter.ts
+++ b/packages/twenty-shared/src/utils/filter/turnRecordFilterIntoGqlOperationFilter.ts
@@ -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,
},
diff --git a/packages/twenty-shared/src/utils/filter/utils/getFilterTypeFromFieldType.ts b/packages/twenty-shared/src/utils/filter/utils/getFilterTypeFromFieldType.ts
index 23d69c54a4..046bdbc0f8 100644
--- a/packages/twenty-shared/src/utils/filter/utils/getFilterTypeFromFieldType.ts
+++ b/packages/twenty-shared/src/utils/filter/utils/getFilterTypeFromFieldType.ts
@@ -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';