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:
+8
@@ -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 =
|
||||
|
||||
+11
@@ -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 {
|
||||
|
||||
+13
-1
@@ -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),
|
||||
});
|
||||
|
||||
|
||||
+41
-5
@@ -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}
|
||||
|
||||
+100
@@ -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();
|
||||
});
|
||||
});
|
||||
+100
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user