feat: implement generic many-to-many junction relation support (#16820)
## Overview
This PR implements **generic many-to-many relation support** through
junction tables (also known as associative entities or join tables).
This replaces the need for hardcoded taskTarget/noteTarget logic and
provides a flexible foundation for modeling complex entity
relationships.
## Architecture
### Data Model
Many-to-many relationships are implemented using a **junction object
pattern**:
```
┌─────────┐ ┌──────────────────┐ ┌─────────┐
│ Pet │──────>│ PetRocket │<──────│ Rocket │
│ │ 1:N │ (junction) │ N:1 │ │
│ rockets ├───────┤ pet : Pet ├───────┤ │
└─────────┘ │ rocket : Rocket │ └─────────┘
└──────────────────┘
```
The junction object (PetRocket) has:
- A `MANY_TO_ONE` relation to **Pet** (the source)
- A `MANY_TO_ONE` relation to **Rocket** (the target)
The source object (Pet) has a `ONE_TO_MANY` relation pointing to the
junction, with **field settings** that specify which target field to
follow.
### Field Settings Schema
Junction configuration is stored in `FieldMetadataRelationSettings`:
```typescript
{
relationType: "ONE_TO_MANY",
// Points to the target field on the junction object
junctionTargetFieldId?: string; // For regular relations
junctionTargetMorphId?: string; // For polymorphic relations
}
```
**Two configuration modes:**
1. **`junctionTargetFieldId`** - References a specific `RELATION` field
on the junction
2. **`junctionTargetMorphId`** - References a `morphId` group for
polymorphic targets (e.g., link to Person OR Company)
### GraphQL Query Generation
When a junction relation is detected, the GraphQL fields are generated
to fetch the nested target:
```graphql
query GetPetWithRockets {
pet(id: "...") {
rockets { # ONE_TO_MANY to junction
id
rocket { # Target field on junction
id
name
__typename
}
}
}
}
```
For polymorphic junction targets:
```graphql
caretakerPerson { id, name }
caretakerCompany { id, name }
```
## Frontend Architecture
### Display Flow
1. **Detection**: `hasJunctionConfig()` checks if field has junction
settings
2. **Config Resolution**: `getJunctionConfig()` resolves junction object
metadata and target fields
3. **Record Extraction**: `extractTargetRecordsFromJunction()` extracts
target records from junction records
4. **Rendering**: Target records displayed as chips (not junction
records)
### Edit Flow
1. **Picker Opening**: Initializes the multi-record picker with:
- Searchable object types (derived from junction target fields)
- Pre-selected items (extracted from existing junction records)
2. **Selection Handling**: Manages create/delete of junction records:
- **Select**: Creates new junction record with source + target IDs
- **Deselect**: Finds and deletes the junction record
- **Optimistic Updates**: Manually updates Recoil store before API call
### Key Trade-offs
| Decision | Trade-off |
|----------|-----------|
| Junction records managed manually | More control over optimistic
updates, but requires manual cache management |
| Settings stored per-field | Flexible (same junction can power
different views), but requires UI to configure |
| Polymorphic via morphId groups | Supports N target types, but adds
query complexity |
| Feature flag gated | Safe rollout, but requires flag management |
## Backend Changes
- **Validation**: Junction target field must exist and be a valid
`MANY_TO_ONE` relation
- **Settings**: Extended `FieldMetadataRelationSettings` type with
junction fields
- **Dev Seeder**: Added sample junction objects (PetRocket,
EmploymentHistory, PetCareAgreement) for testing
## How to Test
1. Enable the `IS_JUNCTION_RELATIONS_ENABLED` feature flag
2. Create objects with junction pattern (Pet → PetRocket → Rocket)
3. Configure the junction target in field settings (advanced mode)
4. Verify:
- Display shows target objects (Rockets), not junction records
(PetRockets)
- Picker allows selecting/deselecting targets
- Changes persist correctly
https://github.com/user-attachments/assets/d04f057a-228c-4de8-af48-76bb2d72cac1
---------
Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
+8
@@ -59,6 +59,12 @@ const dateTimeFieldFormSchema = z
|
||||
.extend(settingsDataModelFieldDateFormSchema.shape)
|
||||
.extend(isUniqueFieldFormSchema.shape);
|
||||
|
||||
const relationFieldFormSchema = z
|
||||
.object({
|
||||
type: z.literal(FieldMetadataType.RELATION),
|
||||
})
|
||||
.extend(settingsDataModelFieldMorphRelationFormSchema.shape);
|
||||
|
||||
const morphRelationFieldFormSchema = z
|
||||
.object({
|
||||
type: z.literal(FieldMetadataType.MORPH_RELATION),
|
||||
@@ -128,6 +134,7 @@ const otherFieldsFormSchema = z
|
||||
omit(SETTINGS_FIELD_TYPE_CONFIGS, [
|
||||
FieldMetadataType.BOOLEAN,
|
||||
FieldMetadataType.CURRENCY,
|
||||
FieldMetadataType.RELATION,
|
||||
FieldMetadataType.MORPH_RELATION,
|
||||
FieldMetadataType.SELECT,
|
||||
FieldMetadataType.MULTI_SELECT,
|
||||
@@ -154,6 +161,7 @@ export const settingsDataModelFieldSettingsFormSchema = z.discriminatedUnion(
|
||||
currencyFieldFormSchema,
|
||||
dateFieldFormSchema,
|
||||
dateTimeFieldFormSchema,
|
||||
relationFieldFormSchema,
|
||||
morphRelationFieldFormSchema,
|
||||
selectFieldFormSchema,
|
||||
multiSelectFieldFormSchema,
|
||||
|
||||
+1
-2
@@ -88,8 +88,7 @@ export const SettingsDataModelFieldRelationForm = ({
|
||||
});
|
||||
|
||||
const initialRelationType =
|
||||
existingFieldMetadataItem?.settings?.relationType ??
|
||||
RelationType.ONE_TO_MANY;
|
||||
existingFieldMetadataItem?.relation?.type ?? RelationType.ONE_TO_MANY;
|
||||
|
||||
const { label: defaultLabelOnDestination, icon: defaultIconOnDestination } =
|
||||
useRelationSettingsFormDefaultValuesTargetFieldMetadata({
|
||||
|
||||
+17
-4
@@ -7,12 +7,15 @@ import {
|
||||
SettingsDataModelFieldRelationForm,
|
||||
type SettingsDataModelFieldMorphRelationFormValues,
|
||||
} from '@/settings/data-model/fields/forms/morph-relation/components/SettingsDataModelFieldRelationForm';
|
||||
import { SettingsDataModelFieldRelationJunctionForm } from '@/settings/data-model/fields/forms/morph-relation/components/SettingsDataModelFieldRelationJunctionForm';
|
||||
import { SettingsDataModelFieldRelationPreviewContent } from '@/settings/data-model/fields/forms/morph-relation/components/SettingsDataModelFieldRelationPreviewContent';
|
||||
import { SettingsDataModelRelationPreviewImage } from '@/settings/data-model/fields/forms/morph-relation/components/SettingsDataModelFieldRelationPreviewImageCard';
|
||||
import { SettingsDataModelRelationFieldPreviewSubWidget } from '@/settings/data-model/fields/preview/components/SettingsDataModelRelationFieldPreviewSubWidget';
|
||||
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
|
||||
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { FieldMetadataType, RelationType } from '~/generated-metadata/graphql';
|
||||
import { FeatureFlagKey } from '~/generated/graphql';
|
||||
import { type SettingsDataModelFieldEditFormValues } from '~/pages/settings/data-model/SettingsObjectFieldEdit';
|
||||
|
||||
type SettingsDataModelFieldRelationFormCardProps = {
|
||||
@@ -31,6 +34,9 @@ export const SettingsDataModelFieldRelationFormCard = ({
|
||||
SettingsDataModelFieldEditFormValues
|
||||
>();
|
||||
const isMobile = useIsMobile();
|
||||
const isJunctionRelationsEnabled = useIsFeatureEnabled(
|
||||
FeatureFlagKey.IS_JUNCTION_RELATIONS_ENABLED,
|
||||
);
|
||||
|
||||
const { objectMetadataItems } = useObjectMetadataItems();
|
||||
|
||||
@@ -107,10 +113,17 @@ export const SettingsDataModelFieldRelationFormCard = ({
|
||||
</SettingsDataModelFieldRelationPreviewContent>
|
||||
}
|
||||
form={
|
||||
<SettingsDataModelFieldRelationForm
|
||||
existingFieldMetadataId={existingFieldMetadataId}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<>
|
||||
<SettingsDataModelFieldRelationForm
|
||||
existingFieldMetadataId={existingFieldMetadataId}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{isJunctionRelationsEnabled && (
|
||||
<SettingsDataModelFieldRelationJunctionForm
|
||||
objectNameSingular={objectNameSingular}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
|
||||
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
|
||||
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
|
||||
import { SettingsOptionCardContentSelect } from '@/settings/components/SettingsOptions/SettingsOptionCardContentSelect';
|
||||
import { SettingsOptionCardContentToggle } from '@/settings/components/SettingsOptions/SettingsOptionCardContentToggle';
|
||||
import { Select } from '@/ui/input/components/Select';
|
||||
import { isAdvancedModeEnabledState } from '@/ui/navigation/navigation-drawer/states/isAdvancedModeEnabledState';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { IconLink } from 'twenty-ui/display';
|
||||
import { RelationType } from '~/generated-metadata/graphql';
|
||||
import { type SettingsDataModelFieldEditFormValues } from '~/pages/settings/data-model/SettingsObjectFieldEdit';
|
||||
|
||||
type SettingsDataModelFieldRelationJunctionFormProps = {
|
||||
objectNameSingular: string;
|
||||
};
|
||||
|
||||
export const SettingsDataModelFieldRelationJunctionForm = ({
|
||||
objectNameSingular,
|
||||
}: SettingsDataModelFieldRelationJunctionFormProps) => {
|
||||
const { t } = useLingui();
|
||||
const { watch, setValue } =
|
||||
useFormContext<SettingsDataModelFieldEditFormValues>();
|
||||
|
||||
const isAdvancedModeEnabled = useRecoilValue(isAdvancedModeEnabledState);
|
||||
|
||||
const { objectMetadataItem: sourceObjectMetadataItem } =
|
||||
useObjectMetadataItem({ objectNameSingular });
|
||||
|
||||
const { objectMetadataItems } = useObjectMetadataItems();
|
||||
|
||||
const relationType = watch('relationType') ?? RelationType.ONE_TO_MANY;
|
||||
const targetObjectIds = watch('morphRelationObjectMetadataIds') ?? [];
|
||||
const junctionTargetFieldId = watch('settings.junctionTargetFieldId');
|
||||
|
||||
// Only applies to ONE_TO_MANY with single target
|
||||
if (
|
||||
!isAdvancedModeEnabled ||
|
||||
relationType !== RelationType.ONE_TO_MANY ||
|
||||
targetObjectIds.length !== 1
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const junctionObjectMetadataItem = objectMetadataItems.find(
|
||||
(item) => item.id === targetObjectIds[0],
|
||||
);
|
||||
|
||||
if (!junctionObjectMetadataItem) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sourceObjectMetadataId = sourceObjectMetadataItem?.id;
|
||||
|
||||
// Self-referential relations cannot be junction objects
|
||||
if (sourceObjectMetadataId === junctionObjectMetadataItem.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build options from junction object fields
|
||||
const junctionFieldOptions: { label: string; value: string }[] = [];
|
||||
|
||||
// Add MORPH_RELATION fields (use first field of each morphId group)
|
||||
// morphRelations already contains all targets, so any sibling works
|
||||
const morphIdsSeen = new Set<string>();
|
||||
junctionObjectMetadataItem.fields
|
||||
.filter(
|
||||
(field) =>
|
||||
field.type === FieldMetadataType.MORPH_RELATION &&
|
||||
isDefined(field.morphId),
|
||||
)
|
||||
.forEach((field) => {
|
||||
if (!morphIdsSeen.has(field.morphId!)) {
|
||||
morphIdsSeen.add(field.morphId!);
|
||||
junctionFieldOptions.push({
|
||||
label: `${field.label} (polymorphic)`,
|
||||
value: field.id,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Add regular MANY_TO_ONE relations (not pointing back to source)
|
||||
junctionObjectMetadataItem.fields
|
||||
.filter((field) => {
|
||||
if (
|
||||
field.type !== FieldMetadataType.RELATION ||
|
||||
field.relation?.type !== RelationType.MANY_TO_ONE
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
!isDefined(sourceObjectMetadataId) ||
|
||||
field.relation?.targetObjectMetadata.id !== sourceObjectMetadataId
|
||||
);
|
||||
})
|
||||
.forEach((field) => {
|
||||
junctionFieldOptions.push({
|
||||
label: field.label,
|
||||
value: field.id,
|
||||
});
|
||||
});
|
||||
|
||||
if (junctionFieldOptions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isJunctionConfigEnabled = isDefined(junctionTargetFieldId);
|
||||
|
||||
const handleJunctionToggle = (checked: boolean) => {
|
||||
if (checked && junctionFieldOptions.length > 0) {
|
||||
setValue(
|
||||
'settings.junctionTargetFieldId',
|
||||
junctionFieldOptions[0].value,
|
||||
{
|
||||
shouldDirty: true,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
setValue('settings.junctionTargetFieldId', undefined, {
|
||||
shouldDirty: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectionChange = (selectedValue: string) => {
|
||||
setValue('settings.junctionTargetFieldId', selectedValue, {
|
||||
shouldDirty: true,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsOptionCardContentToggle
|
||||
Icon={IconLink}
|
||||
title={t`This is a relation to a Junction Object`}
|
||||
description={t`Will show linked records directly instead of intermediate junction record`}
|
||||
checked={isJunctionConfigEnabled}
|
||||
onChange={handleJunctionToggle}
|
||||
divider={isJunctionConfigEnabled}
|
||||
advancedMode
|
||||
/>
|
||||
|
||||
{isJunctionConfigEnabled && (
|
||||
<SettingsOptionCardContentSelect
|
||||
title={t`Target relation on Junction Object`}
|
||||
description={t`Skip the junction object (similar to many-to-many relations)`}
|
||||
>
|
||||
<Select
|
||||
dropdownId="junction-target-field-select"
|
||||
selectSizeVariant="small"
|
||||
dropdownWidth={120}
|
||||
value={junctionTargetFieldId}
|
||||
options={junctionFieldOptions}
|
||||
onChange={handleSelectionChange}
|
||||
/>
|
||||
</SettingsOptionCardContentSelect>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user