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..d527e534b8 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,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 = diff --git a/packages/twenty-front/src/modules/object-record/record-index/hooks/useFindManyRecordIndexTableParams.ts b/packages/twenty-front/src/modules/object-record/record-index/hooks/useFindManyRecordIndexTableParams.ts index 853f2dc3c7..4d37912a99 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/hooks/useFindManyRecordIndexTableParams.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/hooks/useFindManyRecordIndexTableParams.ts @@ -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 { diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/hooks/useAggregateRecordsForRecordTableColumnFooter.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/hooks/useAggregateRecordsForRecordTableColumnFooter.tsx index ad32f6f268..6181776e93 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/hooks/useAggregateRecordsForRecordTableColumnFooter.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-footer/hooks/useAggregateRecordsForRecordTableColumnFooter.tsx @@ -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), }); 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 c62e5cb4f8..f076128f0f 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 @@ -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 ( + > = {}, +): Pick => ({ + 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(); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/field/utils/getRelationTableFilter.ts b/packages/twenty-front/src/modules/page-layout/widgets/field/utils/getRelationTableFilter.ts new file mode 100644 index 0000000000..bd7bd7df41 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/field/utils/getRelationTableFilter.ts @@ -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 + | 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; +};