fix(front): keep relation field record tables scoped to the host record (#21293)

## Problem

When a relation field is added to a record page as a record **table**
widget
(Page Layouts → a `FIELD` widget with `fieldDisplayMode: TABLE` and a
`viewId`),
the table renders the **global** list of the related object instead of
only the
records related to the current record.

Steps to reproduce:
1. On a Company record page layout, add a to-many relation field (e.g.
`Opportunities`) as a widget and set its display mode to **Table** with
a view
   (so it shows columns).
2. Open a Company record.
3. The Opportunities table lists *all* opportunities in the workspace,
not just
   the ones linked to that company.

Note: when the same relation widget has **no** `viewId`, it is correctly
scoped
to the record — but then it can't render custom columns. So custom
columns and
relation-scoping were effectively mutually exclusive.

## Root cause

`FieldWidgetRelationTable` renders the related records through
`RecordTableWidgetRendererContent` using the widget's `viewId`. That
path loads
the view's filters and fetches the related object's records, but **never
applies
the relation filter** that constrains the table to the host record. With
a
`viewId` present, the table therefore shows the whole object.

The relation filter itself already exists elsewhere —
`RecordDetailRelationSection` builds
``{ `${inverseRelationFieldName}Id`: { in: [recordId] } }`` for its
aggregate.
It just isn't applied on the table path.

## Fix

- Add a pure helper `getRelationTableFilter()` that builds the
host-relation
  filter for a to-many relation field (morph-aware, mirroring
  `RecordDetailRelationSection`).
- `FieldWidgetRelationTable` computes this filter and passes it down via
the
  existing `RecordFilterValueDependenciesContext` (new optional
  `relationTableFilter`).
- `useFindManyRecordIndexTableParams` (rows) and
`useAggregateRecordsForRecordTableColumnFooter` (footer aggregates) AND
this
  filter into their queries.

The filter is scoped to the relation-table instance through the context
and
defaults to `undefined`, so **every other table (record index, kanban,
dashboards, …) is unaffected** — `combineFilters` / object spread treat
the
absent filter as a no-op. No backend changes.

## Tests

- New unit tests for `getRelationTableFilter` (to-many → foreign-key
filter;
to-one → none; unresolved relation type / field → none; morph relation;
  missing morph target names → none).
- `nx typecheck twenty-front`, `nx lint twenty-front`, and the new
  `nx test twenty-front` suite pass locally.

## Screenshots

Same record (a "Centre" with 0 related theory allocations and 34 related
orders), same page-layout (relation fields shown as Table widgets with a
view).

**Before** — with a `viewId`, the relation tables show the *global*
lists: the
Theory Allocations table is full of allocations belonging to *other*
records,
and Collateral Orders shows 60 (the whole object's first page) instead
of 34.

<!-- drag the BEFORE screenshot here -->

**After** — the same tables are scoped to the record: Theory Allocations
is
empty (this record has none) and Collateral Orders shows exactly its 34
orders,
with the view's columns (Status / Total Value / Date).

<!-- drag the AFTER screenshot here -->

## Verification

Verified on a self-hosted instance running the equivalent change (the
four
touched files are byte-identical on `main` and the latest release tag):
a
relation table widget with a `viewId` now shows only the host record's
related
rows **with** the view's columns, the footer aggregates match the
visible rows,
and the global record index is unchanged. Confirmed across records with
different related-record counts (e.g. a record with 34 related orders
shows 34;
a record with 1 shows 1; records with 0 show an empty table).

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
rcshetty3
2026-06-22 19:56:33 +05:30
committed by GitHub
parent c171c62099
commit 068a8d4efe
6 changed files with 273 additions and 6 deletions
@@ -1,7 +1,15 @@
import { createContext } from 'react';
import { type RecordGqlOperationFilter } from 'twenty-shared/types';
export type RecordFilterValueDependenciesContextValue = {
currentRecordId?: string;
// When a relation field is rendered as a record table on a record page
// (FieldWidgetRelationTable), this carries a hard filter that scopes the
// table to the host record's related records. It is ANDed into every query
// for that table instance so that supplying a viewId (which provides the
// columns) does not discard the relation filter. Undefined for every other
// table.
relationTableFilter?: RecordGqlOperationFilter;
};
export const RecordFilterValueDependenciesContext =
@@ -3,6 +3,7 @@ import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadat
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
import { turnSortsIntoOrderBy } from '@/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy';
import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState';
import { RecordFilterValueDependenciesContext } from '@/object-record/record-filter/contexts/RecordFilterValueDependenciesContext';
import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies';
import { anyFieldFilterValueComponentState } from '@/object-record/record-filter/states/anyFieldFilterValueComponentState';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
@@ -11,9 +12,11 @@ import { useRecordGroupFilter } from '@/object-record/record-group/hooks/useReco
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useContext } from 'react';
import {
combineFilters,
computeRecordGqlOperationFilter,
isDefined,
turnAnyFieldFilterIntoRecordGqlFilter,
} from 'twenty-shared/utils';
@@ -49,6 +52,13 @@ export const useFindManyRecordIndexTableParams = (
const { filterValueDependencies } = useFilterValueDependencies();
// Hard relation filter injected by FieldWidgetRelationTable so a relation
// field rendered as a record table stays scoped to the host record even when
// a viewId supplies its columns. Undefined for all other tables.
const { relationTableFilter } = useContext(
RecordFilterValueDependenciesContext,
);
const flattenedFieldMetadataItems = useAtomStateValue(
flattenedFieldMetadataItemsSelector,
);
@@ -81,6 +91,7 @@ export const useFindManyRecordIndexTableParams = (
currentFilters,
recordGroupFilter,
anyFieldFilter,
...(isDefined(relationTableFilter) ? [relationTableFilter] : []),
]);
return {
@@ -5,6 +5,7 @@ import { transformAggregateRawValueIntoAggregateDisplayValue } from '@/object-re
import { getAggregateOperationLabel } from '@/object-record/record-board/record-board-column/utils/getAggregateOperationLabel';
import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState';
import { RecordFilterValueDependenciesContext } from '@/object-record/record-filter/contexts/RecordFilterValueDependenciesContext';
import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies';
import { anyFieldFilterValueComponentState } from '@/object-record/record-filter/states/anyFieldFilterValueComponentState';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
@@ -55,6 +56,12 @@ export const useAggregateRecordsForRecordTableColumnFooter = (
const { filterValueDependencies } = useFilterValueDependencies();
// Keep footer aggregates scoped to the host record's related records when this
// table is a relation field widget. Undefined for all other tables.
const { relationTableFilter } = useContext(
RecordFilterValueDependenciesContext,
);
const requestFilters = computeRecordGqlOperationFilter({
fieldMetadataItems: flattenedFieldMetadataItems,
filterValueDependencies,
@@ -117,7 +124,12 @@ export const useAggregateRecordsForRecordTableColumnFooter = (
const { data, loading } = useAggregateRecords({
objectNameSingular: objectMetadataItem.nameSingular,
recordGqlFieldsAggregate,
filter: { ...requestFilters, ...recordGroupFilter, ...anyFieldFilter },
filter: {
...requestFilters,
...recordGroupFilter,
...anyFieldFilter,
...(isDefined(relationTableFilter) ? relationTableFilter : {}),
},
skip: !isDefined(aggregateOperationForViewField),
});
@@ -1,9 +1,12 @@
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { RecordFilterValueDependenciesContext } from '@/object-record/record-filter/contexts/RecordFilterValueDependenciesContext';
import { type FieldDefinition } from '@/object-record/record-field/ui/types/FieldDefinition';
import { type FieldRelationMetadata } from '@/object-record/record-field/ui/types/FieldMetadata';
import { RECORD_TABLE_ROW_HEIGHT } from '@/object-record/record-table/constants/RecordTableRowHeight';
import { useIsPageLayoutInEditMode } from '@/page-layout/hooks/useIsPageLayoutInEditMode';
import { RecordTableWidgetRendererContent } from '@/page-layout/widgets/record-table/components/RecordTableWidgetRendererContent';
import { getRelationTableFilter } from '@/page-layout/widgets/field/utils/getRelationTableFilter';
import { isFieldWidget } from '@/page-layout/widgets/field/utils/isFieldWidget';
import { useCurrentWidget } from '@/page-layout/widgets/hooks/useCurrentWidget';
import { styled } from '@linaria/react';
@@ -39,20 +42,53 @@ export const FieldWidgetRelationTable = ({
? widget.configuration.viewId
: undefined;
const targetObjectMetadataId =
fieldDefinition.metadata.relationObjectMetadataId;
const {
relationFieldMetadataId,
relationObjectMetadataNameSingular,
relationObjectMetadataId,
relationType,
objectMetadataNameSingular,
} = fieldDefinition.metadata;
if (!isDefined(viewId) || !isDefined(targetObjectMetadataId)) {
const { objectMetadataItem: relationObjectMetadataItem } =
useObjectMetadataItem({
objectNameSingular: relationObjectMetadataNameSingular,
});
const { objectMetadataItems } = useObjectMetadataItems();
const recordObjectMetadataItem = objectMetadataItems.find(
(objectMetadataItem) =>
objectMetadataItem.nameSingular === objectMetadataNameSingular,
);
const inverseRelationFieldMetadataItem =
relationObjectMetadataItem.fields.find(
({ id }) => id === relationFieldMetadataId,
);
// Scope the table to the current record's related records, even when the
// widget's viewId provides the columns. Without this, the viewId is rendered
// as a global list and the relation is lost.
const relationTableFilter = getRelationTableFilter({
recordId,
relationType,
inverseRelationFieldMetadataItem,
recordObjectMetadataNameSingular: recordObjectMetadataItem?.nameSingular,
recordObjectMetadataNamePlural: recordObjectMetadataItem?.namePlural,
});
if (!isDefined(viewId) || !isDefined(relationObjectMetadataId)) {
return null;
}
return (
<RecordFilterValueDependenciesContext.Provider
value={{ currentRecordId: recordId }}
value={{ currentRecordId: recordId, relationTableFilter }}
>
<StyledContainer>
<RecordTableWidgetRendererContent
objectMetadataId={targetObjectMetadataId}
objectMetadataId={relationObjectMetadataId}
viewId={viewId}
widgetId={widget.id}
isReadOnly={isPageLayoutInEditMode}
@@ -0,0 +1,100 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { getRelationTableFilter } from '@/page-layout/widgets/field/utils/getRelationTableFilter';
import { FieldMetadataType } from 'twenty-shared/types';
import { RelationType } from '~/generated-metadata/graphql';
const RECORD_ID = '20202020-1111-2222-3333-444444444444';
const buildInverseRelationField = (
overrides: Partial<
Pick<FieldMetadataItem, 'name' | 'type' | 'settings'>
> = {},
): Pick<FieldMetadataItem, 'name' | 'type' | 'settings'> => ({
name: 'company',
type: FieldMetadataType.RELATION,
...overrides,
});
describe('getRelationTableFilter', () => {
it('builds a foreign-key filter for a to-many relation', () => {
expect(
getRelationTableFilter({
recordId: RECORD_ID,
relationType: RelationType.ONE_TO_MANY,
inverseRelationFieldMetadataItem: buildInverseRelationField({
name: 'company',
}),
recordObjectMetadataNameSingular: 'company',
recordObjectMetadataNamePlural: 'companies',
}),
).toEqual({ companyId: { in: [RECORD_ID] } });
});
it('returns undefined for a to-one relation (no host foreign key)', () => {
expect(
getRelationTableFilter({
recordId: RECORD_ID,
relationType: RelationType.MANY_TO_ONE,
inverseRelationFieldMetadataItem: buildInverseRelationField(),
recordObjectMetadataNameSingular: 'company',
recordObjectMetadataNamePlural: 'companies',
}),
).toBeUndefined();
});
it('returns undefined when the relation type is unknown', () => {
expect(
getRelationTableFilter({
recordId: RECORD_ID,
relationType: undefined,
inverseRelationFieldMetadataItem: buildInverseRelationField(),
recordObjectMetadataNameSingular: 'company',
recordObjectMetadataNamePlural: 'companies',
}),
).toBeUndefined();
});
it('returns undefined when the inverse relation field cannot be resolved', () => {
expect(
getRelationTableFilter({
recordId: RECORD_ID,
relationType: RelationType.ONE_TO_MANY,
inverseRelationFieldMetadataItem: undefined,
recordObjectMetadataNameSingular: 'company',
recordObjectMetadataNamePlural: 'companies',
}),
).toBeUndefined();
});
it('resolves the gql field name for a morph relation', () => {
expect(
getRelationTableFilter({
recordId: RECORD_ID,
relationType: RelationType.ONE_TO_MANY,
inverseRelationFieldMetadataItem: buildInverseRelationField({
name: 'target',
type: FieldMetadataType.MORPH_RELATION,
settings: { relationType: RelationType.MANY_TO_ONE },
}),
recordObjectMetadataNameSingular: 'company',
recordObjectMetadataNamePlural: 'companies',
}),
).toEqual({ targetCompanyId: { in: [RECORD_ID] } });
});
it('returns undefined for a morph relation when host object names are missing', () => {
expect(
getRelationTableFilter({
recordId: RECORD_ID,
relationType: RelationType.ONE_TO_MANY,
inverseRelationFieldMetadataItem: buildInverseRelationField({
name: 'target',
type: FieldMetadataType.MORPH_RELATION,
settings: { relationType: RelationType.MANY_TO_ONE },
}),
recordObjectMetadataNameSingular: undefined,
recordObjectMetadataNamePlural: undefined,
}),
).toBeUndefined();
});
});
@@ -0,0 +1,100 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import {
FieldMetadataType,
type RecordGqlOperationFilter,
} from 'twenty-shared/types';
import {
computeMorphRelationGqlFieldName,
isDefined,
} from 'twenty-shared/utils';
import { RelationType } from '~/generated-metadata/graphql';
type GetRelationTableFilterArgs = {
recordId: string;
relationType: RelationType | undefined;
// The inverse relation field (on the related object) pointing back to the
// record's object: its name + "Id" is the foreign key to filter on.
inverseRelationFieldMetadataItem:
| Pick<FieldMetadataItem, 'name' | 'type' | 'settings'>
| undefined;
// The host record's own object names, needed only to resolve the gql field
// name of a morph relation.
recordObjectMetadataNameSingular: string | undefined;
recordObjectMetadataNamePlural: string | undefined;
};
const resolveInverseRelationGqlFieldName = ({
inverseRelationFieldMetadataItem,
recordObjectMetadataNameSingular,
recordObjectMetadataNamePlural,
}: {
inverseRelationFieldMetadataItem: Pick<
FieldMetadataItem,
'name' | 'type' | 'settings'
>;
recordObjectMetadataNameSingular: string | undefined;
recordObjectMetadataNamePlural: string | undefined;
}): string | undefined => {
if (
inverseRelationFieldMetadataItem.type !== FieldMetadataType.MORPH_RELATION
) {
return inverseRelationFieldMetadataItem.name;
}
const settings = inverseRelationFieldMetadataItem.settings;
const morphRelationType =
isDefined(settings) && 'relationType' in settings
? settings.relationType
: undefined;
if (
!isDefined(morphRelationType) ||
!isDefined(recordObjectMetadataNameSingular) ||
!isDefined(recordObjectMetadataNamePlural)
) {
return undefined;
}
return computeMorphRelationGqlFieldName({
fieldName: inverseRelationFieldMetadataItem.name,
relationType: morphRelationType,
targetObjectMetadataNameSingular: recordObjectMetadataNameSingular,
targetObjectMetadataNamePlural: recordObjectMetadataNamePlural,
});
};
// Builds the host-relation filter for a relation field rendered as a record
// table on a record page, so the table stays scoped to the current record's
// related records even when a view supplies its columns. Mirrors the relation
// filter used for aggregates in RecordDetailRelationSection.
// Returns undefined for to-one relations (no host foreign key to filter on) or
// when the inverse relation field name cannot be resolved.
export const getRelationTableFilter = ({
recordId,
relationType,
inverseRelationFieldMetadataItem,
recordObjectMetadataNameSingular,
recordObjectMetadataNamePlural,
}: GetRelationTableFilterArgs): RecordGqlOperationFilter | undefined => {
const isToManyRelation = relationType === RelationType.ONE_TO_MANY;
if (!isToManyRelation || !isDefined(inverseRelationFieldMetadataItem)) {
return undefined;
}
const gqlFieldName = resolveInverseRelationGqlFieldName({
inverseRelationFieldMetadataItem,
recordObjectMetadataNameSingular,
recordObjectMetadataNamePlural,
});
if (!isDefined(gqlFieldName)) {
return undefined;
}
return {
[`${gqlFieldName}Id`]: {
in: [recordId],
},
} satisfies RecordGqlOperationFilter;
};