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:
Félix Malfait
2026-01-20 21:58:13 +01:00
committed by GitHub
parent 0e7e471312
commit 3ed67b825e
102 changed files with 3895 additions and 476 deletions
@@ -0,0 +1,30 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getImageIdentifierFieldMetadataItem } from '@/object-metadata/utils/getImageIdentifierFieldMetadataItem';
import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem';
import { type RecordGqlFields } from '@/object-record/graphql/record-gql-fields/types/RecordGqlFields';
import { isDefined } from 'twenty-shared/utils';
export const buildIdentifierGqlFields = (
objectMetadata: Pick<
ObjectMetadataItem,
| 'fields'
| 'labelIdentifierFieldMetadataId'
| 'imageIdentifierFieldMetadataId'
| 'nameSingular'
>,
): RecordGqlFields => {
const labelIdentifierField =
getLabelIdentifierFieldMetadataItem(objectMetadata);
const imageIdentifierField =
getImageIdentifierFieldMetadataItem(objectMetadata);
return {
id: true,
...(isDefined(labelIdentifierField) && {
[labelIdentifierField.name]: true,
}),
...(isDefined(imageIdentifierField) && {
[imageIdentifierField.name]: true,
}),
};
};
@@ -7,7 +7,12 @@ import { isDefined } from 'twenty-shared/utils';
export type GenerateDepthRecordGqlFields = {
objectMetadataItems: Pick<
ObjectMetadataItem,
'id' | 'nameSingular' | 'fields' | 'labelIdentifierFieldMetadataId'
| 'id'
| 'nameSingular'
| 'namePlural'
| 'fields'
| 'labelIdentifierFieldMetadataId'
| 'imageIdentifierFieldMetadataId'
>[];
activityObjectNameSingular:
| CoreObjectNameSingular.Note
@@ -3,17 +3,24 @@ import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataI
import { getImageIdentifierFieldMetadataItem } from '@/object-metadata/utils/getImageIdentifierFieldMetadataItem';
import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem';
import { type RecordGqlFields } from '@/object-record/graphql/record-gql-fields/types/RecordGqlFields';
import { generateJunctionRelationGqlFields } from '@/object-record/graphql/record-gql-fields/utils/generateJunctionRelationGqlFields';
import { isJunctionRelationField } from '@/object-record/record-field/ui/utils/junction/isJunctionRelationField';
import { FieldMetadataType, RelationType } from 'twenty-shared/types';
import { computeMorphRelationFieldName, isDefined } from 'twenty-shared/utils';
export type GenerateDepthRecordGqlFieldsFromFields = {
objectMetadataItems: Pick<
ObjectMetadataItem,
'id' | 'fields' | 'labelIdentifierFieldMetadataId' | 'nameSingular'
| 'id'
| 'fields'
| 'labelIdentifierFieldMetadataId'
| 'imageIdentifierFieldMetadataId'
| 'nameSingular'
| 'namePlural'
>[];
fields: Pick<
FieldMetadataItem,
'name' | 'type' | 'settings' | 'morphRelations' | 'relation'
'id' | 'name' | 'type' | 'settings' | 'morphRelations' | 'relation'
>[];
depth: 0 | 1;
shouldOnlyLoadRelationIdentifiers?: boolean;
@@ -47,6 +54,20 @@ export const generateDepthRecordGqlFieldsFromFields = ({
);
}
if (isJunctionRelationField(fieldMetadata)) {
const junctionGqlFields = generateJunctionRelationGqlFields({
fieldMetadataItem: fieldMetadata,
objectMetadataItems,
});
if (isDefined(junctionGqlFields) && depth === 1) {
return {
...recordGqlFields,
[fieldMetadata.name]: junctionGqlFields,
};
}
}
const labelIdentifierFieldMetadataItem =
getLabelIdentifierFieldMetadataItem(targetObjectMetadataItem);
@@ -0,0 +1,112 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type RecordGqlFields } from '@/object-record/graphql/record-gql-fields/types/RecordGqlFields';
import { buildIdentifierGqlFields } from '@/object-record/graphql/record-gql-fields/utils/buildIdentifierGqlFields';
import {
getJunctionConfig,
type JunctionObjectMetadataItem,
} from '@/object-record/record-field/ui/utils/junction/getJunctionConfig';
import { FieldMetadataType } from 'twenty-shared/types';
import { computeMorphRelationFieldName, isDefined } from 'twenty-shared/utils';
type JunctionFieldMetadataItem = Pick<
FieldMetadataItem,
'id' | 'name' | 'type' | 'settings' | 'morphRelations' | 'relation'
>;
type GenerateJunctionRelationGqlFieldsArgs = {
fieldMetadataItem: JunctionFieldMetadataItem;
objectMetadataItems: JunctionObjectMetadataItem[];
};
const buildRegularTargetFieldGqlFields = (
targetField: JunctionFieldMetadataItem,
objectMetadataItems: JunctionObjectMetadataItem[],
): RecordGqlFields => {
const targetObjectMetadata = objectMetadataItems.find(
(item) => item.id === targetField.relation?.targetObjectMetadata.id,
);
if (!isDefined(targetObjectMetadata)) {
return {};
}
return {
[targetField.name]: buildIdentifierGqlFields(targetObjectMetadata),
};
};
const buildMorphTargetFieldGqlFields = (
targetField: JunctionFieldMetadataItem,
objectMetadataItems: JunctionObjectMetadataItem[],
): RecordGqlFields => {
const morphRelations = targetField.morphRelations;
if (!Array.isArray(morphRelations) || morphRelations.length === 0) {
return {};
}
const result: RecordGqlFields = {};
for (const morphRelation of morphRelations) {
const targetObjectMetadata = objectMetadataItems.find(
(item) => item.id === morphRelation.targetObjectMetadata.id,
);
if (!isDefined(targetObjectMetadata)) {
continue;
}
const computedFieldName = computeMorphRelationFieldName({
fieldName: morphRelation.sourceFieldMetadata.name,
relationType: morphRelation.type,
targetObjectMetadataNameSingular: targetObjectMetadata.nameSingular,
targetObjectMetadataNamePlural: targetObjectMetadata.namePlural,
});
result[computedFieldName] = buildIdentifierGqlFields(targetObjectMetadata);
}
return result;
};
const buildTargetFieldGqlFields = (
targetField: JunctionFieldMetadataItem,
objectMetadataItems: JunctionObjectMetadataItem[],
): RecordGqlFields => {
if (targetField.type === FieldMetadataType.MORPH_RELATION) {
return buildMorphTargetFieldGqlFields(targetField, objectMetadataItems);
}
return buildRegularTargetFieldGqlFields(targetField, objectMetadataItems);
};
// Generates GraphQL fields for a junction relation, including the nested target objects
export const generateJunctionRelationGqlFields = ({
fieldMetadataItem,
objectMetadataItems,
}: GenerateJunctionRelationGqlFieldsArgs): RecordGqlFields | null => {
const junctionConfig = getJunctionConfig({
settings: fieldMetadataItem.settings,
relationObjectMetadataId:
fieldMetadataItem.relation?.targetObjectMetadata.id ?? '',
objectMetadataItems,
});
if (!isDefined(junctionConfig)) {
return null;
}
const { junctionObjectMetadata, targetFields } = junctionConfig;
const junctionTargetFields = targetFields.reduce<RecordGqlFields>(
(acc, targetField) => ({
...acc,
...buildTargetFieldGqlFields(targetField, objectMetadataItems),
}),
{},
);
return {
...buildIdentifierGqlFields(junctionObjectMetadata),
...junctionTargetFields,
};
};
@@ -75,7 +75,7 @@ export const RecordFieldList = ({
const {
inlineFieldMetadataItems,
inlineRelationFieldMetadataItems,
legacyActivityTargetFieldMetadataItems,
boxedRelationFieldMetadataItems,
} = useFieldListFieldMetadataItems({
objectNameSingular,
@@ -95,7 +95,7 @@ export const RecordFieldList = ({
<PropertyBoxSkeletonLoader />
) : (
<>
{inlineRelationFieldMetadataItems?.map(
{legacyActivityTargetFieldMetadataItems?.map(
(fieldMetadataItem, index) => (
<FieldContext.Provider
key={objectRecordId + fieldMetadataItem.id}
@@ -180,7 +180,8 @@ export const RecordFieldList = ({
}),
onMouseEnter: () =>
handleMouseEnter(
index + (inlineRelationFieldMetadataItems?.length ?? 0),
index +
(legacyActivityTargetFieldMetadataItems?.length ?? 0),
),
anchorId: `${getRecordFieldInputInstanceId({
recordId: objectRecordId,
@@ -21,14 +21,14 @@ export const useFieldListFieldMetadataFromPosition = ({
const {
inlineFieldMetadataItems,
inlineRelationFieldMetadataItems,
legacyActivityTargetFieldMetadataItems,
boxedRelationFieldMetadataItems,
} = useFieldListFieldMetadataItems({
objectNameSingular,
});
const fieldMetadataItems = [
...inlineRelationFieldMetadataItems,
...legacyActivityTargetFieldMetadataItems,
...inlineFieldMetadataItems,
...boxedRelationFieldMetadataItems,
];
@@ -1,13 +1,13 @@
import { useLabelIdentifierFieldMetadataItem } from '@/object-metadata/hooks/useLabelIdentifierFieldMetadataItem';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { getObjectPermissionsForObject } from '@/object-metadata/utils/getObjectPermissionsForObject';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { categorizeRelationFields } from '@/object-record/record-field-list/utils/categorizeRelationFields';
import { isFieldCellSupported } from '@/object-record/utils/isFieldCellSupported';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import groupBy from 'lodash.groupby';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { FeatureFlagKey } from '~/generated/graphql';
type UseFieldListFieldMetadataItemsProps = {
objectNameSingular: string;
@@ -35,6 +35,10 @@ export const useFieldListFieldMetadataItems = ({
const { objectMetadataItems } = useObjectMetadataItems();
const isJunctionRelationsEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_JUNCTION_RELATIONS_ENABLED,
);
const availableFieldMetadataItems = objectMetadataItem.readableFields
.filter(
(fieldMetadataItem) =>
@@ -70,49 +74,22 @@ export const useFieldListFieldMetadataItems = ({
: 'inlineFieldMetadataItems',
);
const inlineRelationFieldMetadataItems = (
relationFieldMetadataItems ?? []
).filter(
(fieldMetadataItem) =>
(objectNameSingular === CoreObjectNameSingular.Note &&
fieldMetadataItem.name === 'noteTargets') ||
(objectNameSingular === CoreObjectNameSingular.Task &&
fieldMetadataItem.name === 'taskTargets'),
);
const boxedRelationFieldMetadataItems = (relationFieldMetadataItems ?? [])
.filter(
(fieldMetadataItem) =>
!(
(objectNameSingular === CoreObjectNameSingular.Note &&
fieldMetadataItem.name === 'noteTargets') ||
(objectNameSingular === CoreObjectNameSingular.Task &&
fieldMetadataItem.name === 'taskTargets')
),
)
.filter((fieldMetadataItem) => {
const canReadRelation =
isDefined(fieldMetadataItem.relation?.targetObjectMetadata.id) &&
getObjectPermissionsForObject(
objectPermissionsByObjectMetadataId,
fieldMetadataItem.relation?.targetObjectMetadata.id,
).canReadObjectRecords;
const canReadMorphRelation = fieldMetadataItem?.morphRelations?.every(
(morphRelation) =>
isDefined(morphRelation.targetObjectMetadata.id) &&
getObjectPermissionsForObject(
objectPermissionsByObjectMetadataId,
morphRelation.targetObjectMetadata.id,
).canReadObjectRecords,
);
return canReadRelation || canReadMorphRelation;
const { activityTargetFields, inlineRelationFields, boxedRelationFields } =
categorizeRelationFields({
relationFields: relationFieldMetadataItems ?? [],
objectNameSingular,
objectPermissionsByObjectMetadataId,
isJunctionRelationsEnabled,
});
const allInlineFieldMetadataItems = [
...(inlineFieldMetadataItems ?? []),
...inlineRelationFields,
].sort((a, b) => a.name.localeCompare(b.name));
return {
inlineFieldMetadataItems,
inlineRelationFieldMetadataItems,
boxedRelationFieldMetadataItems,
inlineFieldMetadataItems: allInlineFieldMetadataItems,
legacyActivityTargetFieldMetadataItems: activityTargetFields,
boxedRelationFieldMetadataItems: boxedRelationFields,
};
};
@@ -0,0 +1,37 @@
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { isActivityTargetField } from '@/object-record/record-field-list/utils/categorizeRelationFields';
describe('isActivityTargetField', () => {
it('should return true for noteTargets field on Note object', () => {
expect(
isActivityTargetField('noteTargets', CoreObjectNameSingular.Note),
).toBe(true);
});
it('should return true for taskTargets field on Task object', () => {
expect(
isActivityTargetField('taskTargets', CoreObjectNameSingular.Task),
).toBe(true);
});
it('should return false for noteTargets field on non-Note object', () => {
expect(
isActivityTargetField('noteTargets', CoreObjectNameSingular.Task),
).toBe(false);
});
it('should return false for taskTargets field on non-Task object', () => {
expect(
isActivityTargetField('taskTargets', CoreObjectNameSingular.Note),
).toBe(false);
});
it('should return false for regular field names', () => {
expect(
isActivityTargetField('company', CoreObjectNameSingular.Person),
).toBe(false);
expect(
isActivityTargetField('people', CoreObjectNameSingular.Company),
).toBe(false);
});
});
@@ -0,0 +1,97 @@
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { getObjectPermissionsForObject } from '@/object-metadata/utils/getObjectPermissionsForObject';
import { isJunctionRelationField } from '@/object-record/record-field/ui/utils/junction/isJunctionRelationField';
import { type ObjectPermissions } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
type ObjectPermissionsByObjectMetadataId = Record<
string,
ObjectPermissions & { objectMetadataId: string }
>;
type CategorizeRelationFieldsArgs = {
relationFields: FieldMetadataItem[];
objectNameSingular: string;
objectPermissionsByObjectMetadataId: ObjectPermissionsByObjectMetadataId;
isJunctionRelationsEnabled: boolean;
};
type CategorizedRelationFields = {
activityTargetFields: FieldMetadataItem[];
inlineRelationFields: FieldMetadataItem[];
boxedRelationFields: FieldMetadataItem[];
};
export const isActivityTargetField = (
fieldName: string,
objectNameSingular: string,
): boolean =>
(objectNameSingular === CoreObjectNameSingular.Note &&
fieldName === 'noteTargets') ||
(objectNameSingular === CoreObjectNameSingular.Task &&
fieldName === 'taskTargets');
const isActivityTargetRelation = (
fieldMetadataItem: FieldMetadataItem,
objectNameSingular: string,
): boolean => isActivityTargetField(fieldMetadataItem.name, objectNameSingular);
const canReadRelationTarget = (
fieldMetadataItem: FieldMetadataItem,
objectPermissionsByObjectMetadataId: ObjectPermissionsByObjectMetadataId,
): boolean => {
const canReadRelation =
isDefined(fieldMetadataItem.relation?.targetObjectMetadata.id) &&
getObjectPermissionsForObject(
objectPermissionsByObjectMetadataId,
fieldMetadataItem.relation?.targetObjectMetadata.id,
).canReadObjectRecords;
const canReadMorphRelation = fieldMetadataItem?.morphRelations?.every(
(morphRelation) =>
isDefined(morphRelation.targetObjectMetadata.id) &&
getObjectPermissionsForObject(
objectPermissionsByObjectMetadataId,
morphRelation.targetObjectMetadata.id,
).canReadObjectRecords,
);
return canReadRelation || (canReadMorphRelation ?? false);
};
export const categorizeRelationFields = ({
relationFields,
objectNameSingular,
objectPermissionsByObjectMetadataId,
isJunctionRelationsEnabled,
}: CategorizeRelationFieldsArgs): CategorizedRelationFields => {
const activityTargetFields: FieldMetadataItem[] = [];
const inlineRelationFields: FieldMetadataItem[] = [];
const boxedRelationFields: FieldMetadataItem[] = [];
for (const field of relationFields) {
// Activity targets are always rendered with ActivityTargetsInlineCell
if (isActivityTargetRelation(field, objectNameSingular)) {
activityTargetFields.push(field);
continue;
}
// Junction relations (when feature enabled) are rendered inline with other fields
if (isJunctionRelationsEnabled && isJunctionRelationField(field)) {
inlineRelationFields.push(field);
continue;
}
// Boxed relations need read permission check
if (canReadRelationTarget(field, objectPermissionsByObjectMetadataId)) {
boxedRelationFields.push(field);
}
}
return {
activityTargetFields,
inlineRelationFields,
boxedRelationFields,
};
};
@@ -48,10 +48,16 @@ export const visibleRecordFieldsComponentSelector = createComponentSelector({
return false;
}
const isActive = isActiveFieldMetadataItem({
objectNameSingular: objectMetadataItem.nameSingular,
fieldMetadata: fieldMetadataItem,
});
const isLabelIdentifier =
fieldMetadataItem.id ===
objectMetadataItem.labelIdentifierFieldMetadataId;
const isActive =
isLabelIdentifier ||
isActiveFieldMetadataItem({
objectNameSingular: objectMetadataItem.nameSingular,
fieldMetadata: fieldMetadataItem,
});
const isReadable = objectMetadataItem.readableFields.some(
findById(fieldMetadataItem.id),
@@ -4,7 +4,9 @@ import { type NoteTarget } from '@/activities/types/NoteTarget';
import { type Task } from '@/activities/types/Task';
import { type TaskTarget } from '@/activities/types/TaskTarget';
import { getActivityTargetObjectRecords } from '@/activities/utils/getActivityTargetObjectRecords';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { useOpenJunctionRelationFieldInput } from '@/object-record/record-field/ui/hooks/useOpenJunctionRelationFieldInput';
import { useOpenMorphRelationManyToOneFieldInput } from '@/object-record/record-field/ui/meta-types/input/hooks/useOpenMorphRelationManyToOneFieldInput';
import { useOpenMorphRelationOneToManyFieldInput } from '@/object-record/record-field/ui/meta-types/input/hooks/useOpenMorphRelationOneToManyFieldInput';
import { useOpenRelationFromManyFieldInput } from '@/object-record/record-field/ui/meta-types/input/hooks/useOpenRelationFromManyFieldInput';
@@ -13,6 +15,7 @@ import { type FieldDefinition } from '@/object-record/record-field/ui/types/Fiel
import {
type FieldMetadata,
type FieldRelationFromManyValue,
type FieldRelationMetadata,
type FieldRelationValue,
} from '@/object-record/record-field/ui/types/FieldMetadata';
import { isFieldMorphRelation } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelation';
@@ -20,6 +23,7 @@ import { isFieldMorphRelationManyToOne } from '@/object-record/record-field/ui/t
import { isFieldMorphRelationOneToMany } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelationOneToMany';
import { isFieldRelationManyToOne } from '@/object-record/record-field/ui/types/guards/isFieldRelationManyToOne';
import { isFieldRelationOneToMany } from '@/object-record/record-field/ui/types/guards/isFieldRelationOneToMany';
import { hasJunctionConfig } from '@/object-record/record-field/ui/utils/junction/hasJunctionConfig';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
import { getRecordFieldInputInstanceId } from '@/object-record/utils/getRecordFieldInputId';
@@ -40,6 +44,9 @@ export const useOpenFieldInputEditMode = () => {
const { openActivityTargetCellEditMode } =
useOpenActivityTargetCellEditMode();
const { openJunctionRelationFieldInput } =
useOpenJunctionRelationFieldInput();
const { openMorphRelationManyToOneFieldInput } =
useOpenMorphRelationManyToOneFieldInput();
@@ -56,6 +63,40 @@ export const useOpenFieldInputEditMode = () => {
recordId: string;
prefix?: string;
}) => {
const objectMetadataItems = snapshot
.getLoadable(objectMetadataItemsState)
.getValue();
const currentWorkspace = snapshot
.getLoadable(currentWorkspaceState)
.getValue();
const isJunctionRelationsEnabled =
currentWorkspace?.featureFlags?.find(
(flag) => (flag.key as string) === 'IS_JUNCTION_RELATIONS_ENABLED',
)?.value ?? false;
const isOneToMany = isFieldRelationOneToMany(fieldDefinition);
const fieldHasJunctionConfig = hasJunctionConfig(
fieldDefinition.metadata.settings,
);
if (
isJunctionRelationsEnabled &&
isOneToMany &&
fieldHasJunctionConfig
) {
openJunctionRelationFieldInput({
fieldDefinition:
fieldDefinition as FieldDefinition<FieldRelationMetadata>,
recordId,
prefix,
});
return;
}
// Backward compatibility: hardcoded taskTarget/noteTarget check
// TODO: Remove this once taskTarget/noteTarget are migrated to use junction configuration
if (
isFieldRelationOneToMany(fieldDefinition) &&
['taskTarget', 'noteTarget'].includes(
@@ -75,10 +116,6 @@ export const useOpenFieldInputEditMode = () => {
.getLoadable(recordStoreFamilyState(recordId))
.getValue();
const objectMetadataItems = snapshot
.getLoadable(objectMetadataItemsState)
.getValue();
const activityTargetObjectRecords = getActivityTargetObjectRecords({
activityRecord: activity as Task | Note,
objectMetadataItems,
@@ -166,6 +203,7 @@ export const useOpenFieldInputEditMode = () => {
},
[
openActivityTargetCellEditMode,
openJunctionRelationFieldInput,
openMorphRelationManyToOneFieldInput,
openMorphRelationOneToManyFieldInput,
openRelationFromManyFieldInput,
@@ -0,0 +1,145 @@
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { type FieldDefinition } from '@/object-record/record-field/ui/types/FieldDefinition';
import {
type FieldRelationFromManyValue,
type FieldRelationMetadata,
type FieldRelationValue,
} from '@/object-record/record-field/ui/types/FieldMetadata';
import { extractTargetRecordsFromJunction } from '@/object-record/record-field/ui/utils/junction/extractTargetRecordsFromJunction';
import { getJunctionConfig } from '@/object-record/record-field/ui/utils/junction/getJunctionConfig';
import { getSearchableObjectMetadataItems } from '@/object-record/record-field/ui/utils/junction/getSearchableObjectMetadataItems';
import { useMultipleRecordPickerOpen } from '@/object-record/record-picker/multiple-record-picker/hooks/useMultipleRecordPickerOpen';
import { useMultipleRecordPickerPerformSearch } from '@/object-record/record-picker/multiple-record-picker/hooks/useMultipleRecordPickerPerformSearch';
import { multipleRecordPickerPickableMorphItemsComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerPickableMorphItemsComponentState';
import { multipleRecordPickerSearchFilterComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerSearchFilterComponentState';
import { multipleRecordPickerSearchableObjectMetadataItemsComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerSearchableObjectMetadataItemsComponentState';
import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
import { getRecordFieldInputInstanceId } from '@/object-record/utils/getRecordFieldInputId';
import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
export const useOpenJunctionRelationFieldInput = () => {
const { performSearch } = useMultipleRecordPickerPerformSearch();
const { openMultipleRecordPicker } = useMultipleRecordPickerOpen();
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
const openJunctionRelationFieldInput = useRecoilCallback(
({ set, snapshot }) =>
({
fieldDefinition,
recordId,
prefix,
}: {
fieldDefinition: FieldDefinition<FieldRelationMetadata>;
recordId: string;
prefix?: string;
}) => {
const objectMetadataItems = snapshot
.getLoadable(objectMetadataItemsState)
.getValue();
const sourceObjectMetadataId = objectMetadataItems.find(
(item) =>
item.nameSingular ===
fieldDefinition.metadata.objectMetadataNameSingular,
)?.id;
const junctionConfig = getJunctionConfig({
settings: fieldDefinition.metadata.settings,
relationObjectMetadataId:
fieldDefinition.metadata.relationObjectMetadataId,
sourceObjectMetadataId,
objectMetadataItems,
});
if (!isDefined(junctionConfig)) {
return;
}
const { targetFields } = junctionConfig;
if (targetFields.length === 0) {
return;
}
const recordPickerInstanceId = getRecordFieldInputInstanceId({
recordId,
fieldName: fieldDefinition.metadata.fieldName,
prefix,
});
const junctionRecords = snapshot
.getLoadable<FieldRelationValue<FieldRelationFromManyValue>>(
recordStoreFamilySelector({
recordId,
fieldName: fieldDefinition.metadata.fieldName,
}),
)
.getValue();
const selectedTargetRecords = extractTargetRecordsFromJunction({
junctionRecords,
targetFields,
objectMetadataItems,
});
const searchableObjectMetadataItems = getSearchableObjectMetadataItems(
targetFields,
objectMetadataItems,
);
const pickableMorphItems = selectedTargetRecords.map((record) => ({
recordId: record.recordId,
objectMetadataId: record.objectMetadataId,
isSelected: true,
isMatchingSearchFilter: true,
}));
set(
multipleRecordPickerPickableMorphItemsComponentState.atomFamily({
instanceId: recordPickerInstanceId,
}),
pickableMorphItems,
);
set(
multipleRecordPickerSearchableObjectMetadataItemsComponentState.atomFamily(
{ instanceId: recordPickerInstanceId },
),
searchableObjectMetadataItems,
);
set(
multipleRecordPickerSearchFilterComponentState.atomFamily({
instanceId: recordPickerInstanceId,
}),
'',
);
openMultipleRecordPicker(recordPickerInstanceId);
performSearch({
multipleRecordPickerInstanceId: recordPickerInstanceId,
forceSearchFilter: '',
forceSearchableObjectMetadataItems: searchableObjectMetadataItems,
forcePickableMorphItems: pickableMorphItems,
});
pushFocusItemToFocusStack({
focusId: recordPickerInstanceId,
component: {
type: FocusComponentType.DROPDOWN,
instanceId: recordPickerInstanceId,
},
globalHotkeysConfig: {
enableGlobalHotkeysConflictingWithKeyboard: false,
},
});
},
[openMultipleRecordPicker, performSearch, pushFocusItemToFocusStack],
);
return { openJunctionRelationFieldInput };
};
@@ -0,0 +1,233 @@
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { getObjectTypename } from '@/object-record/cache/utils/getObjectTypename';
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord';
import { type FieldDefinition } from '@/object-record/record-field/ui/types/FieldDefinition';
import {
type FieldRelationFromManyValue,
type FieldRelationMetadata,
type FieldRelationValue,
} from '@/object-record/record-field/ui/types/FieldMetadata';
import { findJunctionRecordByTargetId } from '@/object-record/record-field/ui/utils/junction/findJunctionRecordByTargetId';
import { findTargetFieldInfo } from '@/object-record/record-field/ui/utils/junction/findTargetFieldInfo';
import { getJunctionConfig } from '@/object-record/record-field/ui/utils/junction/getJunctionConfig';
import { getSourceJoinColumnName } from '@/object-record/record-field/ui/utils/junction/getSourceJoinColumnName';
import { searchRecordStoreFamilyState } from '@/object-record/record-picker/multiple-record-picker/states/searchRecordStoreComponentFamilyState';
import { type RecordPickerPickableMorphItem } from '@/object-record/record-picker/types/RecordPickerPickableMorphItem';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
type UseUpdateJunctionRelationFromCellArgs = {
fieldMetadataItem: FieldMetadataItem;
fieldDefinition: FieldDefinition<FieldRelationMetadata>;
recordId: string;
};
export const useUpdateJunctionRelationFromCell = ({
fieldMetadataItem,
fieldDefinition,
recordId,
}: UseUpdateJunctionRelationFromCellArgs) => {
const { objectMetadataItems } = useObjectMetadataItems();
const sourceObjectMetadata = objectMetadataItems.find(
(item) =>
item.nameSingular === fieldDefinition.metadata.objectMetadataNameSingular,
);
const junctionConfig = getJunctionConfig({
settings: fieldMetadataItem.settings,
relationObjectMetadataId: fieldDefinition.metadata.relationObjectMetadataId,
sourceObjectMetadataId: sourceObjectMetadata?.id,
objectMetadataItems,
});
const junctionObjectMetadata = junctionConfig?.junctionObjectMetadata;
const sourceFieldOnJunction = junctionConfig?.sourceField;
// Use relation object name as fallback to prevent hook errors (hooks can't be conditional)
const junctionObjectNameSingular =
junctionObjectMetadata?.nameSingular ??
fieldDefinition.metadata.relationObjectMetadataNameSingular;
// Skip the post-optimistic effect since we handle optimistic updates manually
// Otherwise Apollo would also add the record, resulting in duplicates
const { createOneRecord: createJunctionRecord } = useCreateOneRecord({
objectNameSingular: junctionObjectNameSingular,
skipPostOptimisticEffect: true,
});
const { deleteOneRecord: deleteJunctionRecord } = useDeleteOneRecord({
objectNameSingular: junctionObjectNameSingular,
});
const updateJunctionRelationFromCell = useRecoilCallback(
({ snapshot, set }) =>
async ({ morphItem }: { morphItem: RecordPickerPickableMorphItem }) => {
const targetFields = junctionConfig?.targetFields;
if (
!isDefined(junctionObjectMetadata) ||
!isDefined(sourceFieldOnJunction) ||
!isDefined(targetFields) ||
targetFields.length === 0
) {
return;
}
if (!isDefined(sourceObjectMetadata)) {
return;
}
const sourceJoinColumnName = getSourceJoinColumnName({
sourceField: sourceFieldOnJunction,
sourceObjectMetadata,
});
const fieldName = fieldDefinition.metadata.fieldName;
const junctionObjectName = junctionObjectMetadata.nameSingular;
const targetFieldInfo = findTargetFieldInfo(
targetFields,
morphItem.objectMetadataId,
objectMetadataItems,
);
if (!isDefined(targetFieldInfo)) {
return;
}
const targetFieldName = targetFieldInfo.fieldName;
const targetJoinColumnName = targetFieldInfo.joinColumnName;
if (
!isDefined(sourceJoinColumnName) ||
!isDefined(targetJoinColumnName)
) {
return;
}
const currentJunctionRecords =
snapshot
.getLoadable<
FieldRelationValue<FieldRelationFromManyValue>
>(recordStoreFamilySelector({ recordId, fieldName }))
.getValue() ?? [];
// morphItem.isSelected represents the NEW state (what the user wants)
if (!morphItem.isSelected) {
const junctionRecordToDelete = findJunctionRecordByTargetId({
junctionRecords: currentJunctionRecords,
targetRecordId: morphItem.recordId,
targetFieldName,
});
if (!isDefined(junctionRecordToDelete)) {
return;
}
await deleteJunctionRecord(junctionRecordToDelete.id);
const currentFieldValue = snapshot
.getLoadable<
FieldRelationValue<FieldRelationFromManyValue>
>(recordStoreFamilySelector({ recordId, fieldName }))
.getValue();
if (
!isDefined(currentFieldValue) ||
!Array.isArray(currentFieldValue)
) {
return;
}
const updatedJunctionRecords = currentFieldValue.filter(
(record) => record.id !== junctionRecordToDelete.id,
);
set(recordStoreFamilyState(recordId), (currentRecord) => {
if (!isDefined(currentRecord)) {
return currentRecord;
}
return {
...currentRecord,
[fieldName]: updatedJunctionRecords,
};
});
} else {
const searchRecord = snapshot
.getLoadable(searchRecordStoreFamilyState(morphItem.recordId))
.getValue();
if (!isDefined(searchRecord?.record)) {
return;
}
const targetRecord = searchRecord.record;
const newJunctionId = v4();
const now = new Date().toISOString();
const junctionRecordForStore = {
id: newJunctionId,
createdAt: now,
updatedAt: now,
__typename: getObjectTypename(junctionObjectName),
[sourceJoinColumnName]: recordId,
[targetJoinColumnName]: morphItem.recordId,
[targetFieldName]: targetRecord,
};
const newJunctionRecordForApi = {
id: newJunctionId,
[sourceJoinColumnName]: recordId,
[targetJoinColumnName]: morphItem.recordId,
};
set(recordStoreFamilyState(recordId), (currentRecord) => {
if (!isDefined(currentRecord)) {
return currentRecord;
}
const currentFieldValue = currentRecord[fieldName];
const updatedJunctionRecords = Array.isArray(currentFieldValue)
? [...currentFieldValue, junctionRecordForStore]
: [junctionRecordForStore];
return {
...currentRecord,
[fieldName]: updatedJunctionRecords,
};
});
await createJunctionRecord(newJunctionRecordForApi);
}
},
[
createJunctionRecord,
deleteJunctionRecord,
fieldDefinition.metadata.fieldName,
junctionConfig,
junctionObjectMetadata,
objectMetadataItems,
recordId,
sourceFieldOnJunction,
sourceObjectMetadata,
],
);
const isJunctionConfigValid =
isDefined(junctionConfig) &&
isDefined(sourceFieldOnJunction) &&
isDefined(junctionConfig.targetFields) &&
junctionConfig.targetFields.length > 0;
return {
updateJunctionRelationFromCell,
isJunctionConfigValid,
};
};
@@ -1,16 +1,22 @@
import { useContext } from 'react';
import { useActivityTargetObjectRecords } from '@/activities/hooks/useActivityTargetObjectRecords';
import { type NoteTarget } from '@/activities/types/NoteTarget';
import { type TaskTarget } from '@/activities/types/TaskTarget';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { RecordChip } from '@/object-record/components/RecordChip';
import { isActivityTargetField } from '@/object-record/record-field-list/utils/categorizeRelationFields';
import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext';
import { useFieldFocus } from '@/object-record/record-field/ui/hooks/useFieldFocus';
import { useRelationFromManyFieldDisplay } from '@/object-record/record-field/ui/meta-types/hooks/useRelationFromManyFieldDisplay';
import { extractTargetRecordsFromJunction } from '@/object-record/record-field/ui/utils/junction/extractTargetRecordsFromJunction';
import { getJunctionConfig } from '@/object-record/record-field/ui/utils/junction/getJunctionConfig';
import { hasJunctionConfig } from '@/object-record/record-field/ui/utils/junction/hasJunctionConfig';
import { ExpandableList } from '@/ui/layout/expandable-list/components/ExpandableList';
import styled from '@emotion/styled';
import { isArray } from '@sniptt/guards';
import { useContext } from 'react';
import { isDefined } from 'twenty-shared/utils';
const StyledContainer = styled.div`
@@ -28,12 +34,28 @@ export const RelationFromManyFieldDisplay = () => {
useRelationFromManyFieldDisplay();
const { isFocused } = useFieldFocus();
const { disableChipClick, triggerEvent } = useContext(FieldContext);
const { objectMetadataItems } = useObjectMetadataItems();
const { fieldName, objectMetadataNameSingular } = fieldDefinition.metadata;
const relationObjectNameSingular =
fieldDefinition?.metadata.relationObjectMetadataNameSingular;
const isJunctionRelation = hasJunctionConfig(
fieldDefinition.metadata.settings,
);
const sourceObjectMetadataId = objectMetadataItems.find(
(item) => item.nameSingular === objectMetadataNameSingular,
)?.id;
const junctionConfig = getJunctionConfig({
settings: fieldDefinition.metadata.settings,
relationObjectMetadataId: fieldDefinition.metadata.relationObjectMetadataId,
sourceObjectMetadataId,
objectMetadataItems,
});
const { activityTargetObjectRecords } = useActivityTargetObjectRecords(
'',
fieldValue as NoteTarget[] | TaskTarget[],
@@ -47,15 +69,14 @@ export const RelationFromManyFieldDisplay = () => {
return null;
}
if (!isDefined(!relationObjectNameSingular)) {
if (!isDefined(relationObjectNameSingular)) {
return null;
}
const isRelationFromActivityTargets =
(fieldName === 'noteTargets' &&
objectMetadataNameSingular === CoreObjectNameSingular.Note) ||
(fieldName === 'taskTargets' &&
objectMetadataNameSingular === CoreObjectNameSingular.Task);
const isRelationFromActivityTargets = isActivityTargetField(
fieldName,
objectMetadataNameSingular ?? '',
);
const isRelationFromManyActivities =
(fieldName === 'noteTargets' &&
@@ -68,47 +89,77 @@ export const RelationFromManyFieldDisplay = () => {
fieldName === 'noteTargets'
? CoreObjectNameSingular.Note
: CoreObjectNameSingular.Task;
const relationFieldName = fieldName === 'noteTargets' ? 'note' : 'task';
return isFocused ? (
const chips = fieldValue
.map((record) => {
if (!isDefined(record) || !isDefined(record[relationFieldName])) {
return undefined;
}
return (
<RecordChip
key={record.id}
objectNameSingular={objectNameSingular}
record={record[relationFieldName]}
forceDisableClick={disableChipClick}
/>
);
})
.filter(isDefined);
if (isFocused) {
return (
<ExpandableList isChipCountDisplayed={isFocused}>
{chips}
</ExpandableList>
);
}
return <StyledContainer>{chips}</StyledContainer>;
}
if (isJunctionRelation && isDefined(junctionConfig)) {
const { targetFields } = junctionConfig;
if (targetFields.length === 0) {
return null;
}
const extractedRecords = extractTargetRecordsFromJunction({
junctionRecords: fieldValue,
targetFields,
objectMetadataItems,
includeRecord: true,
});
const targetRecordsWithMetadata = extractedRecords
.map((extracted) => {
const objectMetadata = objectMetadataItems.find(
(item) => item.id === extracted.objectMetadataId,
);
if (!objectMetadata || !extracted.record) {
return null;
}
return { record: extracted.record, objectMetadata };
})
.filter(isDefined);
return (
<ExpandableList isChipCountDisplayed={isFocused}>
{fieldValue
?.map((record) => {
if (!isDefined(record) || !isDefined(record[relationFieldName])) {
return undefined;
}
return (
<RecordChip
key={record.id}
objectNameSingular={objectNameSingular}
record={record[relationFieldName]}
forceDisableClick={disableChipClick}
/>
);
})
.filter(isDefined)}
{targetRecordsWithMetadata.map(({ record, objectMetadata }) => (
<RecordChip
key={record.id}
objectNameSingular={objectMetadata.nameSingular}
record={record}
forceDisableClick={disableChipClick}
triggerEvent={triggerEvent}
/>
))}
</ExpandableList>
) : (
<StyledContainer>
{fieldValue
?.map((record) => {
if (!isDefined(record) || !isDefined(record[relationFieldName])) {
return undefined;
}
return (
<RecordChip
key={record.id}
objectNameSingular={objectNameSingular}
record={record[relationFieldName]}
forceDisableClick={disableChipClick}
/>
);
})
.filter(isDefined)}
</StyledContainer>
);
} else if (isRelationFromActivityTargets) {
}
if (isRelationFromActivityTargets) {
return (
<ExpandableList isChipCountDisplayed={isFocused}>
{activityTargetObjectRecords.filter(isDefined).map((record) => (
@@ -121,22 +172,22 @@ export const RelationFromManyFieldDisplay = () => {
))}
</ExpandableList>
);
} else {
return (
<ExpandableList isChipCountDisplayed={isFocused}>
{fieldValue?.filter(isDefined).map((record) => {
const recordChipData = generateRecordChipData(record);
return (
<RecordChip
key={recordChipData.recordId}
objectNameSingular={recordChipData.objectNameSingular}
record={record}
forceDisableClick={disableChipClick}
triggerEvent={triggerEvent}
/>
);
})}
</ExpandableList>
);
}
return (
<ExpandableList isChipCountDisplayed={isFocused}>
{fieldValue.filter(isDefined).map((record) => {
const recordChipData = generateRecordChipData(record);
return (
<RecordChip
key={recordChipData.recordId}
objectNameSingular={recordChipData.objectNameSingular}
record={record}
forceDisableClick={disableChipClick}
triggerEvent={triggerEvent}
/>
);
})}
</ExpandableList>
);
};
@@ -2,10 +2,14 @@ import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataIte
import { isFieldFullName } from '@/object-record/record-field/ui/types/guards/isFieldFullName';
import { isFieldNumber } from '@/object-record/record-field/ui/types/guards/isFieldNumber';
import { isFieldText } from '@/object-record/record-field/ui/types/guards/isFieldText';
import { isFieldUuid } from '@/object-record/record-field/ui/types/guards/isFieldUuid';
export const isFieldIdentifierDisplay = (
field: Pick<FieldMetadataItem, 'type'>,
isLabelIdentifier: boolean,
) =>
isLabelIdentifier &&
(isFieldText(field) || isFieldFullName(field) || isFieldNumber(field));
(isFieldText(field) ||
isFieldFullName(field) ||
isFieldNumber(field) ||
isFieldUuid(field));
@@ -2,6 +2,7 @@ import { PreComputedChipGeneratorsContext } from '@/object-metadata/contexts/Pre
import { isFieldFullName } from '@/object-record/record-field/ui/types/guards/isFieldFullName';
import { isFieldNumber } from '@/object-record/record-field/ui/types/guards/isFieldNumber';
import { isFieldText } from '@/object-record/record-field/ui/types/guards/isFieldText';
import { isFieldUuid } from '@/object-record/record-field/ui/types/guards/isFieldUuid';
import { isNonEmptyString } from '@sniptt/guards';
import { useContext } from 'react';
import { useRecoilValue } from 'recoil';
@@ -44,7 +45,8 @@ export const useChipFieldDisplay = () => {
isFieldText(fieldDefinition) ||
isFieldFullName(fieldDefinition) ||
isFieldNumber(fieldDefinition) ||
isFieldActor(fieldDefinition)
isFieldActor(fieldDefinition) ||
isFieldUuid(fieldDefinition)
? fieldDefinition.metadata.objectMetadataNameSingular
: undefined;
@@ -1,4 +1,6 @@
import { useContext } from 'react';
import { useRecoilCallback } from 'recoil';
import { v4 } from 'uuid';
import { useActivityTargetObjectRecords } from '@/activities/hooks/useActivityTargetObjectRecords';
import { useUpdateActivityTargetFromCell } from '@/activities/inline-cell/hooks/useUpdateActivityTargetFromCell';
@@ -6,10 +8,13 @@ import { type NoteTarget } from '@/activities/types/NoteTarget';
import { type TaskTarget } from '@/activities/types/TaskTarget';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { type CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { getFieldMetadataItemById } from '@/object-metadata/utils/getFieldMetadataItemById';
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
import { isActivityTargetField } from '@/object-record/record-field-list/utils/categorizeRelationFields';
import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext';
import { FieldInputEventContext } from '@/object-record/record-field/ui/contexts/FieldInputEventContext';
import { useUpdateJunctionRelationFromCell } from '@/object-record/record-field/ui/hooks/useUpdateJunctionRelationFromCell';
import { useRelationField } from '@/object-record/record-field/ui/meta-types/hooks/useRelationField';
import { useAddNewRecordAndOpenRightDrawer } from '@/object-record/record-field/ui/meta-types/input/hooks/useAddNewRecordAndOpenRightDrawer';
import { useUpdateRelationOneToManyFieldInput } from '@/object-record/record-field/ui/meta-types/input/hooks/useUpdateRelationOneToManyFieldInput';
@@ -17,13 +22,18 @@ import { RecordFieldComponentInstanceContext } from '@/object-record/record-fiel
import { recordFieldInputLayoutDirectionComponentState } from '@/object-record/record-field/ui/states/recordFieldInputLayoutDirectionComponentState';
import { type FieldDefinition } from '@/object-record/record-field/ui/types/FieldDefinition';
import { type FieldRelationMetadata } from '@/object-record/record-field/ui/types/FieldMetadata';
import { getJoinColumnName } from '@/object-record/record-field/ui/utils/junction/getJoinColumnName';
import { getJunctionConfig } from '@/object-record/record-field/ui/utils/junction/getJunctionConfig';
import { getSourceJoinColumnName } from '@/object-record/record-field/ui/utils/junction/getSourceJoinColumnName';
import { hasJunctionConfig } from '@/object-record/record-field/ui/utils/junction/hasJunctionConfig';
import { MultipleRecordPicker } from '@/object-record/record-picker/multiple-record-picker/components/MultipleRecordPicker';
import { useMultipleRecordPickerPerformSearch } from '@/object-record/record-picker/multiple-record-picker/hooks/useMultipleRecordPickerPerformSearch';
import { multipleRecordPickerPickableMorphItemsComponentState } from '@/object-record/record-picker/multiple-record-picker/states/multipleRecordPickerPickableMorphItemsComponentState';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { buildRecordLabelPayload } from '@/object-record/utils/buildRecordLabelPayload';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useRecoilCallback } from 'recoil';
import { CustomError, isDefined } from 'twenty-shared/utils';
export const RelationOneToManyFieldInput = () => {
@@ -63,11 +73,12 @@ export const RelationOneToManyFieldInput = () => {
onSubmit?.({ skipPersist: true });
};
const isRelationFromActivityTargets =
(fieldName === 'noteTargets' &&
objectMetadataNameSingular === CoreObjectNameSingular.Note) ||
(fieldName === 'taskTargets' &&
objectMetadataNameSingular === CoreObjectNameSingular.Task);
const isRelationFromActivityTargets = isActivityTargetField(
fieldName,
objectMetadataNameSingular ?? '',
);
const isJunctionRelation = hasJunctionConfig(fieldMetadataItem.settings);
const { activityTargetObjectRecords } = useActivityTargetObjectRecords(
recordId,
@@ -77,6 +88,36 @@ export const RelationOneToManyFieldInput = () => {
const relationFieldDefinition =
fieldDefinition as FieldDefinition<FieldRelationMetadata>;
const { updateJunctionRelationFromCell, isJunctionConfigValid } =
useUpdateJunctionRelationFromCell({
fieldMetadataItem,
fieldDefinition: relationFieldDefinition,
recordId,
});
const junctionConfig =
isJunctionRelation && isJunctionConfigValid
? getJunctionConfig({
settings: fieldMetadataItem.settings,
relationObjectMetadataId:
relationFieldDefinition.metadata.relationObjectMetadataId,
sourceObjectMetadataId: objectMetadataItem.id,
objectMetadataItems,
})
: null;
const junctionTargetObjectMetadata = (() => {
if (!junctionConfig || junctionConfig.isMorphRelation) {
return undefined;
}
const firstTargetField = junctionConfig.targetFields[0];
return objectMetadataItems.find(
(item) => item.id === firstTargetField?.relation?.targetObjectMetadata.id,
);
})();
const isMorphJunction = junctionConfig?.isMorphRelation ?? false;
const { objectMetadataItem: relationObjectMetadataItem } =
useObjectMetadataItem({
objectNameSingular:
@@ -104,6 +145,17 @@ export const RelationOneToManyFieldInput = () => {
recordId,
});
const { createOneRecord: createTargetRecord } = useCreateOneRecord({
objectNameSingular:
junctionTargetObjectMetadata?.nameSingular ??
relationFieldDefinition.metadata.relationObjectMetadataNameSingular,
});
const { createOneRecord: createJunctionRecord } = useCreateOneRecord({
objectNameSingular:
junctionConfig?.junctionObjectMetadata?.nameSingular ??
relationFieldDefinition.metadata.relationObjectMetadataNameSingular,
});
const layoutDirection = useRecoilComponentValue(
recordFieldInputLayoutDirectionComponentState,
);
@@ -119,43 +171,131 @@ export const RelationOneToManyFieldInput = () => {
const handleCreateNew = useRecoilCallback(
({ snapshot, set }) =>
async (searchInput?: string) => {
const newRecordId =
await createNewRecordAndOpenRightDrawer?.(searchInput);
const updatePickerState = (
newRecordId: string,
targetObjectMetadataId: string,
searchableObjectMetadataItems: (typeof relationObjectMetadataItem)[],
) => {
const currentMorphItems = snapshot
.getLoadable(multipleRecordPickerPickableMorphItemsCallbackState)
.getValue();
if (!isDefined(newRecordId)) {
const newMorphItems = currentMorphItems.concat({
recordId: newRecordId,
objectMetadataId: targetObjectMetadataId,
isSelected: true,
isMatchingSearchFilter: true,
});
set(
multipleRecordPickerPickableMorphItemsCallbackState,
newMorphItems,
);
multipleRecordPickerPerformSearch({
multipleRecordPickerInstanceId: instanceId,
forceSearchFilter: searchInput,
forceSearchableObjectMetadataItems: searchableObjectMetadataItems,
forcePickableMorphItems: newMorphItems,
});
};
if (
isJunctionRelation &&
isDefined(junctionConfig) &&
!isMorphJunction &&
isDefined(junctionTargetObjectMetadata)
) {
const { targetFields, sourceField } = junctionConfig;
const targetField = targetFields[0];
if (!targetField || !sourceField) {
return;
}
const sourceJoinColumnName = getSourceJoinColumnName({
sourceField,
sourceObjectMetadata: objectMetadataItem,
});
const targetJoinColumnName = getJoinColumnName(targetField.settings);
if (!sourceJoinColumnName || !targetJoinColumnName) {
return;
}
const newTargetId = v4();
const targetPayload = buildRecordLabelPayload({
id: newTargetId,
searchInput,
objectMetadataItem: junctionTargetObjectMetadata,
});
await createTargetRecord(targetPayload);
const newJunctionId = v4();
const createdJunction = await createJunctionRecord({
id: newJunctionId,
[sourceJoinColumnName]: recordId,
[targetJoinColumnName]: newTargetId,
});
if (isDefined(createdJunction)) {
set(recordStoreFamilyState(recordId), (currentRecord) => {
if (!isDefined(currentRecord)) {
return currentRecord;
}
const currentFieldValue = currentRecord[fieldName];
const updatedJunctionRecords = Array.isArray(currentFieldValue)
? [...currentFieldValue, createdJunction]
: [createdJunction];
return { ...currentRecord, [fieldName]: updatedJunctionRecords };
});
}
updatePickerState(newTargetId, junctionTargetObjectMetadata.id, [
junctionTargetObjectMetadata,
]);
return;
}
const multipleRecordPickerPickableMorphItems = snapshot
.getLoadable(multipleRecordPickerPickableMorphItemsCallbackState)
.getValue();
const newRecordId =
await createNewRecordAndOpenRightDrawer?.(searchInput);
const newMorphItems = multipleRecordPickerPickableMorphItems.concat({
recordId: newRecordId,
objectMetadataId: relationObjectMetadataItem.id,
isSelected: true,
isMatchingSearchFilter: true,
});
set(multipleRecordPickerPickableMorphItemsCallbackState, newMorphItems);
multipleRecordPickerPerformSearch({
multipleRecordPickerInstanceId: instanceId,
forceSearchFilter: searchInput,
forceSearchableObjectMetadataItems: [relationObjectMetadataItem],
forcePickableMorphItems: newMorphItems,
});
if (isDefined(newRecordId)) {
updatePickerState(newRecordId, relationObjectMetadataItem.id, [
relationObjectMetadataItem,
]);
}
},
[
createNewRecordAndOpenRightDrawer,
relationObjectMetadataItem,
createTargetRecord,
createJunctionRecord,
fieldName,
instanceId,
isMorphJunction,
isJunctionRelation,
junctionConfig,
junctionTargetObjectMetadata,
multipleRecordPickerPickableMorphItemsCallbackState,
multipleRecordPickerPerformSearch,
objectMetadataItem,
recordId,
relationObjectMetadataItem,
],
);
const canCreateNew = !isRelationFromActivityTargets;
// Disable "Add New" for activity targets and MORPH junction relations
// (For MORPH, we don't know which object type to create)
const canCreateNew = !isRelationFromActivityTargets && !isMorphJunction;
// For junction relations, use the target object for "Add New", not the junction object
const objectMetadataItemIdForCreate =
isJunctionRelation && isDefined(junctionTargetObjectMetadata)
? junctionTargetObjectMetadata.id
: relationObjectMetadataItem.id;
return (
<MultipleRecordPicker
@@ -169,12 +309,16 @@ export const RelationOneToManyFieldInput = () => {
activityTargetWithTargetRecords: activityTargetObjectRecords,
recordPickerInstanceId: instanceId,
});
} else if (isJunctionRelation && isJunctionConfigValid) {
updateJunctionRelationFromCell({
morphItem,
});
} else {
updateRelation(morphItem);
}
}}
onCreate={canCreateNew ? handleCreateNew : undefined}
objectMetadataItemIdForCreate={relationObjectMetadataItem.id}
objectMetadataItemIdForCreate={objectMetadataItemIdForCreate}
onClickOutside={handleSubmit}
layoutDirection={
layoutDirection === 'downward'
@@ -6,12 +6,12 @@ import { useOpenRecordInCommandMenu } from '@/command-menu/hooks/useOpenRecordIn
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem';
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
import { useUpdateOneRecordV2 } from '@/object-record/hooks/useUpdateOneRecordV2';
import { viewableRecordIdState } from '@/object-record/record-right-drawer/states/viewableRecordIdState';
import { viewableRecordNameSingularState } from '@/object-record/record-right-drawer/states/viewableRecordNameSingularState';
import { buildRecordLabelPayload } from '@/object-record/utils/buildRecordLabelPayload';
import { getOperationName } from '@apollo/client/utilities';
import { computeMorphRelationFieldName, isDefined } from 'twenty-shared/utils';
import { FieldMetadataType, RelationType } from '~/generated-metadata/graphql';
@@ -62,28 +62,12 @@ export const useAddNewRecordAndOpenRightDrawer = ({
return {
createNewRecordAndOpenRightDrawer: async (searchInput?: string) => {
const newRecordId = v4();
const labelIdentifierType = getLabelIdentifierFieldMetadataItem(
relationObjectMetadataItem,
)?.type;
const createRecordPayload: {
id: string;
name:
| string
| { firstName: string | undefined; lastName: string | undefined };
[key: string]: any;
} =
labelIdentifierType === FieldMetadataType.FULL_NAME
? {
id: newRecordId,
name:
searchInput && searchInput.split(' ').length > 1
? {
firstName: searchInput.split(' ')[0],
lastName: searchInput.split(' ').slice(1).join(' '),
}
: { firstName: searchInput, lastName: '' },
}
: { id: newRecordId, name: searchInput ?? '' };
const createRecordPayload = buildRecordLabelPayload({
id: newRecordId,
searchInput,
objectMetadataItem: relationObjectMetadataItem,
});
if (relationFieldMetadataItemRelationType === RelationType.MANY_TO_ONE) {
const gqlField =
@@ -143,6 +143,15 @@ export type FieldPositionMetadata = BaseFieldMetadata & {
settings?: null;
};
export type FieldRelationMetadataSettings = {
relationType?: RelationType;
// Join column name for the foreign key (e.g., "petId" for a "pet" relation)
joinColumnName?: string | null;
// Points to the target field on the junction object
// For MORPH_RELATION fields, morphRelations already contains all targets
junctionTargetFieldId?: string;
} | null;
// for later: refactor this in order to directly use relation without mapping
export type FieldRelationMetadata = BaseFieldMetadata & {
relationFieldMetadataId: string;
@@ -152,14 +161,14 @@ export type FieldRelationMetadata = BaseFieldMetadata & {
relationType?: RelationType;
targetFieldMetadataName?: string;
useEditButton?: boolean;
settings?: null;
settings?: FieldRelationMetadataSettings;
};
export type FieldMorphRelationMetadata = BaseFieldMetadata & {
morphRelations: FieldMetadataItemRelation[];
relationType: RelationType;
useEditButton?: boolean;
settings?: null;
settings?: FieldRelationMetadataSettings;
};
export type FieldSelectMetadata = BaseFieldMetadata & {
@@ -0,0 +1,343 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type FieldMetadataItemRelation } from '@/object-metadata/types/FieldMetadataItemRelation';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { extractTargetRecordsFromJunction } from '@/object-record/record-field/ui/utils/junction/extractTargetRecordsFromJunction';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { FieldMetadataType } from 'twenty-shared/types';
import { RelationType } from '~/generated-metadata/graphql';
const createMockRelation = (
targetObjectId: string,
targetObjectName: string,
sourceFieldName = 'sourceField',
): FieldMetadataItemRelation => ({
type: RelationType.MANY_TO_ONE,
sourceFieldMetadata: { id: 'source-field-id', name: sourceFieldName },
targetFieldMetadata: {
id: 'target-field-id',
name: 'targetField',
isCustom: false,
},
sourceObjectMetadata: {
id: 'source-object-id',
nameSingular: 'sourceObject',
namePlural: 'sourceObjects',
},
targetObjectMetadata: {
id: targetObjectId,
nameSingular: targetObjectName,
namePlural: `${targetObjectName}s`,
},
});
const mockObjectMetadataItems: ObjectMetadataItem[] = [
{
id: 'company-metadata-id',
nameSingular: 'company',
namePlural: 'companies',
} as ObjectMetadataItem,
{
id: 'person-metadata-id',
nameSingular: 'person',
namePlural: 'people',
} as ObjectMetadataItem,
];
const mockTargetField: FieldMetadataItem = {
id: 'target-field-id',
name: 'company',
type: FieldMetadataType.RELATION,
relation: createMockRelation('company-metadata-id', 'company'),
} as FieldMetadataItem;
// Mock morph field with morphRelations (how real data looks)
const mockMorphFieldWithRelations: FieldMetadataItem = {
id: 'morph-field-id',
name: 'caretaker',
morphId: 'morph-group-1',
type: FieldMetadataType.MORPH_RELATION,
morphRelations: [
createMockRelation('company-metadata-id', 'company', 'caretaker'),
createMockRelation('person-metadata-id', 'person', 'caretaker'),
],
} as FieldMetadataItem;
// Mock multiple regular relation fields (for multiple target testing)
const mockMultipleRelationFields: FieldMetadataItem[] = [
{
id: 'relation-field-company-id',
name: 'company',
type: FieldMetadataType.RELATION,
relation: createMockRelation('company-metadata-id', 'company'),
} as FieldMetadataItem,
{
id: 'relation-field-person-id',
name: 'person',
type: FieldMetadataType.RELATION,
relation: createMockRelation('person-metadata-id', 'person'),
} as FieldMetadataItem,
];
const createMockJunctionRecord = (
id: string,
targetData: Record<string, unknown>,
): ObjectRecord =>
({
id,
__typename: 'JunctionObject',
...targetData,
}) as ObjectRecord;
describe('extractTargetRecordsFromJunction', () => {
describe('with null/undefined junction records', () => {
it('should return empty array for undefined junction records', () => {
const result = extractTargetRecordsFromJunction({
junctionRecords: undefined,
targetFields: [mockTargetField],
objectMetadataItems: mockObjectMetadataItems,
});
expect(result).toEqual([]);
});
it('should return empty array for null junction records', () => {
const result = extractTargetRecordsFromJunction({
junctionRecords: null,
targetFields: [mockTargetField],
objectMetadataItems: mockObjectMetadataItems,
});
expect(result).toEqual([]);
});
it('should return empty array for empty targetFields', () => {
const junctionRecords = [
createMockJunctionRecord('junction-1', {
company: { id: 'company-1', name: 'Acme Corp' },
}),
];
const result = extractTargetRecordsFromJunction({
junctionRecords,
targetFields: [],
objectMetadataItems: mockObjectMetadataItems,
});
expect(result).toEqual([]);
});
});
describe('with single target field (regular relations)', () => {
it('should extract target records from junction records', () => {
const junctionRecords = [
createMockJunctionRecord('junction-1', {
company: { id: 'company-1', name: 'Acme Corp' },
}),
createMockJunctionRecord('junction-2', {
company: { id: 'company-2', name: 'Beta Inc' },
}),
];
const result = extractTargetRecordsFromJunction({
junctionRecords,
targetFields: [mockTargetField],
objectMetadataItems: mockObjectMetadataItems,
});
expect(result).toEqual([
{ recordId: 'company-1', objectMetadataId: 'company-metadata-id' },
{ recordId: 'company-2', objectMetadataId: 'company-metadata-id' },
]);
});
it('should include record when includeRecord is true', () => {
const junctionRecords = [
createMockJunctionRecord('junction-1', {
company: { id: 'company-1', name: 'Acme Corp' },
}),
];
const result = extractTargetRecordsFromJunction({
junctionRecords,
targetFields: [mockTargetField],
objectMetadataItems: mockObjectMetadataItems,
includeRecord: true,
});
expect(result).toEqual([
{
recordId: 'company-1',
objectMetadataId: 'company-metadata-id',
record: { id: 'company-1', name: 'Acme Corp' },
},
]);
});
it('should skip junction records with null target', () => {
const junctionRecords = [
createMockJunctionRecord('junction-1', { company: null }),
createMockJunctionRecord('junction-2', {
company: { id: 'company-1', name: 'Acme Corp' },
}),
];
const result = extractTargetRecordsFromJunction({
junctionRecords,
targetFields: [mockTargetField],
objectMetadataItems: mockObjectMetadataItems,
});
expect(result).toHaveLength(1);
expect(result[0].recordId).toBe('company-1');
});
});
describe('with multiple target fields (multiple regular relations)', () => {
it('should extract target records from different target fields', () => {
const junctionRecords = [
createMockJunctionRecord('junction-1', {
company: { id: 'company-1', name: 'Acme Corp' },
}),
createMockJunctionRecord('junction-2', {
person: { id: 'person-1', name: 'John Doe' },
}),
];
const result = extractTargetRecordsFromJunction({
junctionRecords,
targetFields: mockMultipleRelationFields,
objectMetadataItems: mockObjectMetadataItems,
});
expect(result).toHaveLength(2);
expect(result[0]).toEqual({
recordId: 'company-1',
objectMetadataId: 'company-metadata-id',
});
expect(result[1]).toEqual({
recordId: 'person-1',
objectMetadataId: 'person-metadata-id',
});
});
it('should return correct object metadata for each target field', () => {
const junctionRecords = [
createMockJunctionRecord('junction-1', {
person: { id: 'person-1', name: 'Jane Doe' },
}),
];
const result = extractTargetRecordsFromJunction({
junctionRecords,
targetFields: mockMultipleRelationFields,
objectMetadataItems: mockObjectMetadataItems,
});
expect(result[0].objectMetadataId).toBe('person-metadata-id');
});
it('should include record when includeRecord is true', () => {
const junctionRecords = [
createMockJunctionRecord('junction-1', {
company: { id: 'company-1', name: 'Acme Corp' },
}),
];
const result = extractTargetRecordsFromJunction({
junctionRecords,
targetFields: mockMultipleRelationFields,
objectMetadataItems: mockObjectMetadataItems,
includeRecord: true,
});
expect(result[0]).toEqual({
recordId: 'company-1',
objectMetadataId: 'company-metadata-id',
record: { id: 'company-1', name: 'Acme Corp' },
});
});
});
describe('with morph relation field', () => {
// MORPH_RELATION fields use computed field names like "caretakerCompany", "caretakerPerson"
it('should extract target records from morph relation computed field names', () => {
const junctionRecords = [
createMockJunctionRecord('junction-1', {
caretakerCompany: { id: 'company-1', name: 'Acme Corp' },
}),
createMockJunctionRecord('junction-2', {
caretakerPerson: { id: 'person-1', name: 'John Doe' },
}),
];
const result = extractTargetRecordsFromJunction({
junctionRecords,
targetFields: [mockMorphFieldWithRelations],
objectMetadataItems: mockObjectMetadataItems,
});
expect(result).toHaveLength(2);
expect(result[0]).toEqual({
recordId: 'company-1',
objectMetadataId: 'company-metadata-id',
});
expect(result[1]).toEqual({
recordId: 'person-1',
objectMetadataId: 'person-metadata-id',
});
});
it('should include record when includeRecord is true for morph relations', () => {
const junctionRecords = [
createMockJunctionRecord('junction-1', {
caretakerCompany: { id: 'company-1', name: 'Acme Corp' },
}),
];
const result = extractTargetRecordsFromJunction({
junctionRecords,
targetFields: [mockMorphFieldWithRelations],
objectMetadataItems: mockObjectMetadataItems,
includeRecord: true,
});
expect(result[0]).toEqual({
recordId: 'company-1',
objectMetadataId: 'company-metadata-id',
record: { id: 'company-1', name: 'Acme Corp' },
});
});
});
describe('edge cases', () => {
it('should skip undefined junction records in array', () => {
const junctionRecords = [
undefined as unknown as ObjectRecord,
createMockJunctionRecord('junction-1', {
company: { id: 'company-1', name: 'Acme Corp' },
}),
];
const result = extractTargetRecordsFromJunction({
junctionRecords,
targetFields: [mockTargetField],
objectMetadataItems: mockObjectMetadataItems,
});
expect(result).toHaveLength(1);
});
it('should return empty when no target field matches the junction record data', () => {
const junctionRecords = [
createMockJunctionRecord('junction-1', {
unknownField: { id: 'record-1', name: 'Unknown' },
}),
];
const result = extractTargetRecordsFromJunction({
junctionRecords,
targetFields: [mockTargetField],
objectMetadataItems: mockObjectMetadataItems,
});
expect(result).toEqual([]);
});
});
});
@@ -0,0 +1,134 @@
import { findJunctionRecordByTargetId } from '@/object-record/record-field/ui/utils/junction/findJunctionRecordByTargetId';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
const createMockJunctionRecord = (
id: string,
targetData: Record<string, unknown>,
): ObjectRecord =>
({
id,
__typename: 'JunctionObject',
...targetData,
}) as ObjectRecord;
describe('findJunctionRecordByTargetId', () => {
it('should return undefined for empty junction records', () => {
const result = findJunctionRecordByTargetId({
junctionRecords: [],
targetRecordId: 'target-1',
targetFieldName: 'company',
});
expect(result).toBeUndefined();
});
it('should find junction record by target field name', () => {
const junctionRecords = [
createMockJunctionRecord('junction-1', {
company: { id: 'company-1', name: 'Acme Corp' },
}),
createMockJunctionRecord('junction-2', {
company: { id: 'company-2', name: 'Beta Inc' },
}),
];
const result = findJunctionRecordByTargetId({
junctionRecords,
targetRecordId: 'company-2',
targetFieldName: 'company',
});
expect(result?.id).toBe('junction-2');
});
it('should return undefined when target record is not found', () => {
const junctionRecords = [
createMockJunctionRecord('junction-1', {
company: { id: 'company-1', name: 'Acme Corp' },
}),
];
const result = findJunctionRecordByTargetId({
junctionRecords,
targetRecordId: 'non-existent',
targetFieldName: 'company',
});
expect(result).toBeUndefined();
});
it('should skip undefined junction records', () => {
const junctionRecords = [
undefined as unknown as ObjectRecord,
createMockJunctionRecord('junction-1', {
company: { id: 'company-1', name: 'Acme Corp' },
}),
];
const result = findJunctionRecordByTargetId({
junctionRecords,
targetRecordId: 'company-1',
targetFieldName: 'company',
});
expect(result?.id).toBe('junction-1');
});
it('should handle null target objects', () => {
const junctionRecords = [
createMockJunctionRecord('junction-1', { company: null }),
createMockJunctionRecord('junction-2', {
company: { id: 'company-1', name: 'Acme Corp' },
}),
];
const result = findJunctionRecordByTargetId({
junctionRecords,
targetRecordId: 'company-1',
targetFieldName: 'company',
});
expect(result?.id).toBe('junction-2');
});
it('should handle target objects without id property', () => {
const junctionRecords = [
createMockJunctionRecord('junction-1', {
company: { name: 'No ID Company' },
}),
createMockJunctionRecord('junction-2', {
company: { id: 'company-1', name: 'Acme Corp' },
}),
];
const result = findJunctionRecordByTargetId({
junctionRecords,
targetRecordId: 'company-1',
targetFieldName: 'company',
});
expect(result?.id).toBe('junction-2');
});
it('should work with different target field names', () => {
const junctionRecords = [
createMockJunctionRecord('junction-1', {
person: { id: 'person-1', name: 'John Doe' },
company: { id: 'company-1', name: 'Acme Corp' },
}),
];
const personResult = findJunctionRecordByTargetId({
junctionRecords,
targetRecordId: 'person-1',
targetFieldName: 'person',
});
expect(personResult?.id).toBe('junction-1');
const companyResult = findJunctionRecordByTargetId({
junctionRecords,
targetRecordId: 'company-1',
targetFieldName: 'company',
});
expect(companyResult?.id).toBe('junction-1');
});
});
@@ -0,0 +1,30 @@
import { getJoinColumnName } from '@/object-record/record-field/ui/utils/junction/getJoinColumnName';
describe('getJoinColumnName', () => {
it('should return undefined for undefined settings', () => {
expect(getJoinColumnName(undefined)).toBeUndefined();
});
it('should return undefined for null settings', () => {
expect(getJoinColumnName(null as unknown as undefined)).toBeUndefined();
});
it('should return undefined for non-object settings', () => {
expect(getJoinColumnName('string' as unknown as undefined)).toBeUndefined();
});
it('should return undefined for settings without joinColumnName', () => {
expect(getJoinColumnName({})).toBeUndefined();
});
it('should return undefined for settings with non-string joinColumnName', () => {
expect(getJoinColumnName({ joinColumnName: 123 })).toBeUndefined();
});
it('should return the joinColumnName for valid settings', () => {
expect(getJoinColumnName({ joinColumnName: 'personId' })).toBe('personId');
expect(getJoinColumnName({ joinColumnName: 'companyId' })).toBe(
'companyId',
);
});
});
@@ -0,0 +1,240 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type FieldMetadataItemRelation } from '@/object-metadata/types/FieldMetadataItemRelation';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getJunctionConfig } from '@/object-record/record-field/ui/utils/junction/getJunctionConfig';
import { FieldMetadataType } from 'twenty-shared/types';
import { RelationType } from '~/generated-metadata/graphql';
const createMockRelation = (
targetObjectId: string,
targetObjectName: string,
type: RelationType = RelationType.MANY_TO_ONE,
): FieldMetadataItemRelation => ({
type,
sourceFieldMetadata: { id: 'source-field-id', name: 'sourceField' },
targetFieldMetadata: {
id: 'target-field-id',
name: 'targetField',
isCustom: false,
},
sourceObjectMetadata: {
id: 'source-object-id',
nameSingular: 'sourceObject',
namePlural: 'sourceObjects',
},
targetObjectMetadata: {
id: targetObjectId,
nameSingular: targetObjectName,
namePlural: `${targetObjectName}s`,
},
});
const createMockField = (
overrides: Partial<FieldMetadataItem>,
): FieldMetadataItem =>
({
id: 'default-field-id',
name: 'defaultField',
type: FieldMetadataType.RELATION,
...overrides,
}) as FieldMetadataItem;
const createMockObjectMetadata = (
overrides: Partial<ObjectMetadataItem>,
): ObjectMetadataItem =>
({
id: 'default-object-id',
nameSingular: 'defaultObject',
namePlural: 'defaultObjects',
fields: [],
...overrides,
}) as ObjectMetadataItem;
describe('getJunctionConfig', () => {
describe('basic scenarios', () => {
it('should return null when junction object metadata not found', () => {
const result = getJunctionConfig({
settings: { junctionTargetFieldId: 'field-id' },
relationObjectMetadataId: 'non-existent-id',
objectMetadataItems: [],
});
expect(result).toBeNull();
});
it('should return null when settings has no junction config', () => {
const junctionObject = createMockObjectMetadata({
id: 'junction-id',
fields: [],
});
const result = getJunctionConfig({
settings: {},
relationObjectMetadataId: 'junction-id',
objectMetadataItems: [junctionObject],
});
expect(result).toBeNull();
});
it('should return null when settings is undefined', () => {
const junctionObject = createMockObjectMetadata({
id: 'junction-id',
});
const result = getJunctionConfig({
settings: undefined,
relationObjectMetadataId: 'junction-id',
objectMetadataItems: [junctionObject],
});
expect(result).toBeNull();
});
});
describe('junctionTargetFieldId configuration', () => {
it('should return targetFields for regular relation', () => {
const targetField = createMockField({
id: 'target-field-id',
name: 'company',
type: FieldMetadataType.RELATION,
relation: createMockRelation('company-metadata-id', 'company'),
});
const junctionObject = createMockObjectMetadata({
id: 'junction-id',
fields: [targetField],
});
const companyObject = createMockObjectMetadata({
id: 'company-metadata-id',
nameSingular: 'company',
});
const result = getJunctionConfig({
settings: { junctionTargetFieldId: 'target-field-id' },
relationObjectMetadataId: 'junction-id',
objectMetadataItems: [junctionObject, companyObject],
});
expect(result).not.toBeNull();
expect(result!.isMorphRelation).toBe(false);
expect(result!.targetFields).toHaveLength(1);
expect(result!.targetFields[0].name).toBe('company');
});
it('should return null when target field not found', () => {
const junctionObject = createMockObjectMetadata({
id: 'junction-id',
fields: [],
});
const result = getJunctionConfig({
settings: { junctionTargetFieldId: 'non-existent-field' },
relationObjectMetadataId: 'junction-id',
objectMetadataItems: [junctionObject],
});
expect(result).toBeNull();
});
it('should return null for regular relation without relation property', () => {
const targetField = createMockField({
id: 'target-field-id',
name: 'company',
type: FieldMetadataType.RELATION,
relation: undefined,
});
const junctionObject = createMockObjectMetadata({
id: 'junction-id',
fields: [targetField],
});
const result = getJunctionConfig({
settings: { junctionTargetFieldId: 'target-field-id' },
relationObjectMetadataId: 'junction-id',
objectMetadataItems: [junctionObject],
});
expect(result).toBeNull();
});
it('should handle MORPH_RELATION field referenced by ID', () => {
const morphField = createMockField({
id: 'morph-field-id',
name: 'linkedObject',
type: FieldMetadataType.MORPH_RELATION,
});
const junctionObject = createMockObjectMetadata({
id: 'junction-id',
fields: [morphField],
});
const result = getJunctionConfig({
settings: { junctionTargetFieldId: 'morph-field-id' },
relationObjectMetadataId: 'junction-id',
objectMetadataItems: [junctionObject],
});
expect(result).not.toBeNull();
expect(result!.isMorphRelation).toBe(true);
expect(result!.targetFields).toHaveLength(1);
expect(result!.targetFields[0].name).toBe('linkedObject');
});
it('should find sourceField excluding the target field', () => {
const sourceField = createMockField({
id: 'source-field',
name: 'project',
type: FieldMetadataType.RELATION,
relation: createMockRelation('source-object-id', 'project'),
});
const targetField = createMockField({
id: 'target-field-id',
name: 'company',
type: FieldMetadataType.RELATION,
relation: createMockRelation('company-metadata-id', 'company'),
});
const junctionObject = createMockObjectMetadata({
id: 'junction-id',
fields: [sourceField, targetField],
});
const result = getJunctionConfig({
settings: { junctionTargetFieldId: 'target-field-id' },
relationObjectMetadataId: 'junction-id',
sourceObjectMetadataId: 'source-object-id',
objectMetadataItems: [junctionObject],
});
expect(result).not.toBeNull();
expect(result!.sourceField).toBeDefined();
expect(result!.sourceField!.name).toBe('project');
});
it('should find sourceField from morph relation when sourceObjectMetadataId is provided', () => {
const sourceField = createMockField({
id: 'source-field',
name: 'project',
type: FieldMetadataType.RELATION,
relation: createMockRelation('source-object-id', 'project'),
});
const morphField = createMockField({
id: 'morph-field-1',
name: 'company',
morphId: 'morph-group-1',
type: FieldMetadataType.MORPH_RELATION,
});
const junctionObject = createMockObjectMetadata({
id: 'junction-id',
fields: [sourceField, morphField],
});
const result = getJunctionConfig({
settings: { junctionTargetFieldId: 'morph-field-1' },
relationObjectMetadataId: 'junction-id',
sourceObjectMetadataId: 'source-object-id',
objectMetadataItems: [junctionObject],
});
expect(result).not.toBeNull();
expect(result!.sourceField).toBeDefined();
expect(result!.sourceField!.name).toBe('project');
});
});
});
@@ -0,0 +1,83 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { getTargetObjectMetadataIdsFromField } from '@/object-record/record-field/ui/utils/junction/getTargetObjectMetadataIdsFromField';
describe('getTargetObjectMetadataIdsFromField', () => {
it('should return empty array for field without morphRelations and relation', () => {
const field = {
type: FieldMetadataType.TEXT,
} as FieldMetadataItem;
expect(getTargetObjectMetadataIdsFromField(field)).toEqual([]);
});
it('should return target id from relation field', () => {
const field = {
type: FieldMetadataType.RELATION,
relation: {
targetObjectMetadata: {
id: 'target-object-id',
},
},
} as FieldMetadataItem;
expect(getTargetObjectMetadataIdsFromField(field)).toEqual([
'target-object-id',
]);
});
it('should return empty array for relation field without target', () => {
const field = {
type: FieldMetadataType.RELATION,
relation: {
targetObjectMetadata: {},
},
} as unknown as FieldMetadataItem;
expect(getTargetObjectMetadataIdsFromField(field)).toEqual([]);
});
it('should return target ids from morphRelations', () => {
const field = {
type: FieldMetadataType.MORPH_RELATION,
morphRelations: [
{ targetObjectMetadata: { id: 'target-1' } },
{ targetObjectMetadata: { id: 'target-2' } },
],
} as FieldMetadataItem;
expect(getTargetObjectMetadataIdsFromField(field)).toEqual([
'target-1',
'target-2',
]);
});
it('should filter out undefined ids from morphRelations', () => {
const field = {
type: FieldMetadataType.MORPH_RELATION,
morphRelations: [
{ targetObjectMetadata: { id: 'target-1' } },
{ targetObjectMetadata: {} },
],
} as unknown as FieldMetadataItem;
expect(getTargetObjectMetadataIdsFromField(field)).toEqual(['target-1']);
});
it('should prefer morphRelations over relation', () => {
const field = {
type: FieldMetadataType.MORPH_RELATION,
morphRelations: [{ targetObjectMetadata: { id: 'morph-target' } }],
relation: {
targetObjectMetadata: {
id: 'relation-target',
},
},
} as FieldMetadataItem;
expect(getTargetObjectMetadataIdsFromField(field)).toEqual([
'morph-target',
]);
});
});
@@ -0,0 +1,19 @@
import { hasJunctionConfig } from '@/object-record/record-field/ui/utils/junction/hasJunctionConfig';
describe('hasJunctionConfig', () => {
it('should return false for undefined settings', () => {
expect(hasJunctionConfig(undefined)).toBe(false);
});
it('should return false for settings without junctionTargetFieldId', () => {
expect(hasJunctionConfig({})).toBe(false);
});
it('should return false for settings with empty junctionTargetFieldId', () => {
expect(hasJunctionConfig({ junctionTargetFieldId: '' })).toBe(false);
});
it('should return true for settings with valid junctionTargetFieldId', () => {
expect(hasJunctionConfig({ junctionTargetFieldId: 'field-id' })).toBe(true);
});
});
@@ -0,0 +1,37 @@
import { hasJunctionTargetFieldId } from '@/object-record/record-field/ui/utils/junction/hasJunctionTargetFieldId';
describe('hasJunctionTargetFieldId', () => {
it('should return false for undefined settings', () => {
expect(hasJunctionTargetFieldId(undefined)).toBe(false);
});
it('should return false for null settings', () => {
expect(hasJunctionTargetFieldId(null as unknown as undefined)).toBe(false);
});
it('should return false for non-object settings', () => {
expect(hasJunctionTargetFieldId('string' as unknown as undefined)).toBe(
false,
);
});
it('should return false for settings without junctionTargetFieldId', () => {
expect(hasJunctionTargetFieldId({})).toBe(false);
});
it('should return false for settings with non-string junctionTargetFieldId', () => {
expect(hasJunctionTargetFieldId({ junctionTargetFieldId: 123 })).toBe(
false,
);
});
it('should return false for settings with empty string junctionTargetFieldId', () => {
expect(hasJunctionTargetFieldId({ junctionTargetFieldId: '' })).toBe(false);
});
it('should return true for settings with valid junctionTargetFieldId', () => {
expect(
hasJunctionTargetFieldId({ junctionTargetFieldId: 'some-field-id' }),
).toBe(true);
});
});
@@ -0,0 +1,85 @@
import { hasJunctionConfig } from '@/object-record/record-field/ui/utils/junction/hasJunctionConfig';
import { hasJunctionTargetFieldId } from '@/object-record/record-field/ui/utils/junction/hasJunctionTargetFieldId';
import { isJunctionRelationField } from '@/object-record/record-field/ui/utils/junction/isJunctionRelationField';
import { FieldMetadataType } from 'twenty-shared/types';
describe('isJunctionRelation', () => {
describe('hasJunctionTargetFieldId', () => {
it.each([
{ settings: undefined, expected: false, desc: 'undefined' },
{ settings: null, expected: false, desc: 'null' },
{ settings: {}, expected: false, desc: 'empty object' },
{
settings: { junctionTargetFieldId: '' },
expected: false,
desc: 'empty string',
},
{
settings: { junctionTargetFieldId: 'field-id' },
expected: true,
desc: 'valid value',
},
])('returns $expected for $desc', ({ settings, expected }) => {
expect(hasJunctionTargetFieldId(settings)).toBe(expected);
});
it('narrows type correctly', () => {
const settings = { junctionTargetFieldId: 'field-id-1' };
if (hasJunctionTargetFieldId(settings)) {
expect(settings.junctionTargetFieldId).toBe('field-id-1');
}
});
});
describe('hasJunctionConfig', () => {
it.each([
{ settings: undefined, expected: false, desc: 'undefined' },
{ settings: {}, expected: false, desc: 'empty object' },
{
settings: { junctionTargetFieldId: 'field-id' },
expected: true,
desc: 'fieldId set',
},
{
settings: { junctionTargetFieldId: '' },
expected: false,
desc: 'empty fieldId',
},
])('returns $expected for $desc', ({ settings, expected }) => {
expect(hasJunctionConfig(settings)).toBe(expected);
});
});
describe('isJunctionRelationField', () => {
it.each([
{
field: { type: FieldMetadataType.TEXT, settings: null },
expected: false,
desc: 'non-relation field',
},
{
field: { type: FieldMetadataType.RELATION, settings: null },
expected: false,
desc: 'relation without junction config',
},
{
field: {
type: FieldMetadataType.RELATION,
settings: { junctionTargetFieldId: 'field-id' },
},
expected: true,
desc: 'relation with junction config',
},
{
field: {
type: FieldMetadataType.MORPH_RELATION,
settings: { junctionTargetFieldId: 'field-id' },
},
expected: false,
desc: 'morph relation (not supported)',
},
])('returns $expected for $desc', ({ field, expected }) => {
expect(isJunctionRelationField(field)).toBe(expected);
});
});
});
@@ -0,0 +1,32 @@
import { isObjectWithId } from '@/object-record/record-field/ui/utils/junction/isObjectWithId';
describe('isObjectWithId', () => {
it('should return false for undefined', () => {
expect(isObjectWithId(undefined)).toBe(false);
});
it('should return false for null', () => {
expect(isObjectWithId(null)).toBe(false);
});
it('should return false for non-object values', () => {
expect(isObjectWithId('string')).toBe(false);
expect(isObjectWithId(123)).toBe(false);
expect(isObjectWithId(true)).toBe(false);
});
it('should return false for object without id property', () => {
expect(isObjectWithId({ name: 'test' })).toBe(false);
});
it('should return false for object with non-string id', () => {
expect(isObjectWithId({ id: 123 })).toBe(false);
expect(isObjectWithId({ id: null })).toBe(false);
expect(isObjectWithId({ id: undefined })).toBe(false);
});
it('should return true for object with string id', () => {
expect(isObjectWithId({ id: 'some-id' })).toBe(true);
expect(isObjectWithId({ id: 'uuid-123', name: 'test' })).toBe(true);
});
});
@@ -0,0 +1,138 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { isObjectWithId } from '@/object-record/record-field/ui/utils/junction/isObjectWithId';
import { type ExtractedTargetRecord } from '@/object-record/record-field/ui/utils/junction/types/ExtractedTargetRecord';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { FieldMetadataType } from 'twenty-shared/types';
import { computeMorphRelationFieldName, isDefined } from 'twenty-shared/utils';
type ExtractTargetRecordsFromJunctionArgs = {
junctionRecords: ObjectRecord[] | undefined | null;
targetFields: FieldMetadataItem[];
objectMetadataItems: ObjectMetadataItem[];
includeRecord?: boolean;
};
const tryExtractFromField = (
junctionRecord: ObjectRecord,
fieldName: string,
objectMetadataId: string,
includeRecord: boolean,
): ExtractedTargetRecord | null => {
const targetObject = junctionRecord[fieldName];
if (!isObjectWithId(targetObject)) {
return null;
}
return {
recordId: targetObject.id,
objectMetadataId,
...(includeRecord && { record: targetObject }),
};
};
const extractFromTargetFields = (
junctionRecord: ObjectRecord,
targetFields: FieldMetadataItem[],
objectMetadataItems: ObjectMetadataItem[],
includeRecord: boolean,
): ExtractedTargetRecord | null => {
for (const targetField of targetFields) {
if (targetField.type === FieldMetadataType.MORPH_RELATION) {
const result = extractFromMorphRelationField(
junctionRecord,
targetField,
objectMetadataItems,
includeRecord,
);
if (isDefined(result)) {
return result;
}
continue;
}
const targetObjectMetadata = objectMetadataItems.find(
(item) => item.id === targetField.relation?.targetObjectMetadata.id,
);
if (isDefined(targetObjectMetadata)) {
const result = tryExtractFromField(
junctionRecord,
targetField.name,
targetObjectMetadata.id,
includeRecord,
);
if (isDefined(result)) {
return result;
}
}
}
return null;
};
const extractFromMorphRelationField = (
junctionRecord: ObjectRecord,
targetField: FieldMetadataItem,
objectMetadataItems: ObjectMetadataItem[],
includeRecord: boolean,
): ExtractedTargetRecord | null => {
const morphRelations = targetField.morphRelations;
if (!Array.isArray(morphRelations) || morphRelations.length === 0) {
return null;
}
for (const morphRelation of morphRelations) {
const computedFieldName = computeMorphRelationFieldName({
fieldName: morphRelation.sourceFieldMetadata.name,
relationType: morphRelation.type,
targetObjectMetadataNameSingular:
morphRelation.targetObjectMetadata.nameSingular,
targetObjectMetadataNamePlural:
morphRelation.targetObjectMetadata.namePlural,
});
const targetObjectMetadata = objectMetadataItems.find(
(item) => item.id === morphRelation.targetObjectMetadata.id,
);
if (isDefined(targetObjectMetadata)) {
const result = tryExtractFromField(
junctionRecord,
computedFieldName,
targetObjectMetadata.id,
includeRecord,
);
if (isDefined(result)) {
return result;
}
}
}
return null;
};
export const extractTargetRecordsFromJunction = ({
junctionRecords,
targetFields,
objectMetadataItems,
includeRecord = false,
}: ExtractTargetRecordsFromJunctionArgs): ExtractedTargetRecord[] => {
if (!Array.isArray(junctionRecords) || targetFields.length === 0) {
return [];
}
return junctionRecords
.filter(isDefined)
.map((junctionRecord) =>
extractFromTargetFields(
junctionRecord,
targetFields,
objectMetadataItems,
includeRecord,
),
)
.filter(isDefined);
};
@@ -0,0 +1,29 @@
import { isObjectWithId } from '@/object-record/record-field/ui/utils/junction/isObjectWithId';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { isDefined } from 'twenty-shared/utils';
type FindJunctionRecordByTargetIdArgs = {
junctionRecords: ObjectRecord[];
targetRecordId: string;
targetFieldName: string;
};
export const findJunctionRecordByTargetId = ({
junctionRecords,
targetRecordId,
targetFieldName,
}: FindJunctionRecordByTargetIdArgs): ObjectRecord | undefined => {
for (const junctionRecord of junctionRecords) {
if (!isDefined(junctionRecord)) {
continue;
}
const targetObject = junctionRecord[targetFieldName];
if (isObjectWithId(targetObject) && targetObject.id === targetRecordId) {
return junctionRecord;
}
}
return undefined;
};
@@ -0,0 +1,77 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getJoinColumnName } from '@/object-record/record-field/ui/utils/junction/getJoinColumnName';
import { computeMorphRelationFieldName, isDefined } from 'twenty-shared/utils';
export type TargetFieldInfo = {
fieldName: string;
joinColumnName?: string;
};
const findMorphTargetFieldInfo = (
field: FieldMetadataItem,
targetObjectMetadataId: string,
objectMetadataItems: ObjectMetadataItem[],
): TargetFieldInfo | undefined => {
if (!isDefined(field.morphRelations) || field.morphRelations.length === 0) {
return undefined;
}
const matchingMorphRelation = field.morphRelations.find(
(morphRelation) =>
morphRelation.targetObjectMetadata.id === targetObjectMetadataId,
);
if (!isDefined(matchingMorphRelation)) {
return undefined;
}
const targetObjectMetadata = objectMetadataItems.find(
(item) => item.id === targetObjectMetadataId,
);
if (!isDefined(targetObjectMetadata)) {
return undefined;
}
const fieldName = computeMorphRelationFieldName({
fieldName: matchingMorphRelation.sourceFieldMetadata.name,
relationType: matchingMorphRelation.type,
targetObjectMetadataNameSingular: targetObjectMetadata.nameSingular,
targetObjectMetadataNamePlural: targetObjectMetadata.namePlural,
});
// For morph relations, compute the join column name from the computed field name
// e.g., caretakerPerson → caretakerPersonId
return {
fieldName,
joinColumnName: `${fieldName}Id`,
};
};
export const findTargetFieldInfo = (
targetFields: FieldMetadataItem[],
targetObjectMetadataId: string,
objectMetadataItems: ObjectMetadataItem[],
): TargetFieldInfo | undefined => {
for (const field of targetFields) {
const morphResult = findMorphTargetFieldInfo(
field,
targetObjectMetadataId,
objectMetadataItems,
);
if (isDefined(morphResult)) {
return morphResult;
}
if (field.relation?.targetObjectMetadata.id === targetObjectMetadataId) {
return {
fieldName: field.name,
joinColumnName: getJoinColumnName(field.settings),
};
}
}
return undefined;
};
@@ -0,0 +1,16 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { isDefined } from 'twenty-shared/utils';
export const getJoinColumnName = (
settings: FieldMetadataItem['settings'],
): string | undefined => {
if (
isDefined(settings) &&
typeof settings === 'object' &&
'joinColumnName' in settings &&
typeof settings.joinColumnName === 'string'
) {
return settings.joinColumnName;
}
return undefined;
};
@@ -0,0 +1,98 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { hasJunctionTargetFieldId } from './hasJunctionTargetFieldId';
export type JunctionObjectMetadataItem = Pick<
ObjectMetadataItem,
| 'id'
| 'fields'
| 'labelIdentifierFieldMetadataId'
| 'imageIdentifierFieldMetadataId'
| 'nameSingular'
| 'namePlural'
>;
export type JunctionConfig = {
junctionObjectMetadata: JunctionObjectMetadataItem;
targetFields: FieldMetadataItem[];
sourceField?: FieldMetadataItem;
isMorphRelation: boolean;
};
type GetJunctionConfigArgs = {
settings: FieldMetadataItem['settings'] | undefined;
relationObjectMetadataId: string;
sourceObjectMetadataId?: string;
objectMetadataItems: JunctionObjectMetadataItem[];
};
export const getJunctionConfig = ({
settings,
relationObjectMetadataId,
sourceObjectMetadataId,
objectMetadataItems,
}: GetJunctionConfigArgs): JunctionConfig | null => {
const junctionObjectMetadata = objectMetadataItems.find(
(item) => item.id === relationObjectMetadataId,
);
if (!isDefined(junctionObjectMetadata)) {
return null;
}
const findSourceField = (
excludeFieldId?: string,
): FieldMetadataItem | undefined => {
if (!isDefined(sourceObjectMetadataId)) {
return undefined;
}
const relationField = junctionObjectMetadata.fields.find(
(field) =>
field.type === FieldMetadataType.RELATION &&
field.relation?.targetObjectMetadata.id === sourceObjectMetadataId &&
field.id !== excludeFieldId,
);
if (isDefined(relationField)) {
return relationField;
}
return junctionObjectMetadata.fields.find(
(field) =>
field.type === FieldMetadataType.MORPH_RELATION &&
field.id !== excludeFieldId &&
field.morphRelations?.some(
(morphRelation) =>
morphRelation.targetObjectMetadata.id === sourceObjectMetadataId,
),
);
};
if (!hasJunctionTargetFieldId(settings)) {
return null;
}
const targetField = junctionObjectMetadata.fields.find(
(field) => field.id === settings.junctionTargetFieldId,
);
if (!isDefined(targetField)) {
return null;
}
const isMorphRelation = targetField.type === FieldMetadataType.MORPH_RELATION;
if (!isMorphRelation && !isDefined(targetField.relation)) {
return null;
}
return {
junctionObjectMetadata,
targetFields: [targetField],
sourceField: findSourceField(settings.junctionTargetFieldId),
isMorphRelation,
};
};
@@ -0,0 +1,18 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getTargetObjectMetadataIdsFromField } from './getTargetObjectMetadataIdsFromField';
import { isDefined } from 'twenty-shared/utils';
export const getSearchableObjectMetadataItems = (
targetFields: FieldMetadataItem[],
objectMetadataItems: ObjectMetadataItem[],
): ObjectMetadataItem[] => {
const targetObjectIds = targetFields.flatMap(
getTargetObjectMetadataIdsFromField,
);
const uniqueTargetObjectIds = [...new Set(targetObjectIds)];
return uniqueTargetObjectIds
.map((id) => objectMetadataItems.find((item) => item.id === id))
.filter(isDefined);
};
@@ -0,0 +1,39 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getJoinColumnName } from '@/object-record/record-field/ui/utils/junction/getJoinColumnName';
import { FieldMetadataType } from 'twenty-shared/types';
import { computeMorphRelationFieldName, isDefined } from 'twenty-shared/utils';
type GetSourceJoinColumnNameArgs = {
sourceField: Pick<FieldMetadataItem, 'type' | 'morphRelations' | 'settings'>;
sourceObjectMetadata: Pick<
ObjectMetadataItem,
'id' | 'nameSingular' | 'namePlural'
>;
};
export const getSourceJoinColumnName = ({
sourceField,
sourceObjectMetadata,
}: GetSourceJoinColumnNameArgs): string | undefined => {
if (sourceField.type === FieldMetadataType.MORPH_RELATION) {
const morphRelation = sourceField.morphRelations?.find(
(mr) => mr.targetObjectMetadata.id === sourceObjectMetadata.id,
);
if (!isDefined(morphRelation)) {
return undefined;
}
const computedFieldName = computeMorphRelationFieldName({
fieldName: morphRelation.sourceFieldMetadata.name,
relationType: morphRelation.type,
targetObjectMetadataNameSingular: sourceObjectMetadata.nameSingular,
targetObjectMetadataNamePlural: sourceObjectMetadata.namePlural,
});
return `${computedFieldName}Id`;
}
return getJoinColumnName(sourceField.settings);
};
@@ -0,0 +1,15 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { isDefined } from 'twenty-shared/utils';
export const getTargetObjectMetadataIdsFromField = (
field: FieldMetadataItem,
): string[] => {
if (isDefined(field.morphRelations) && field.morphRelations.length > 0) {
return field.morphRelations
.map((morphRelation) => morphRelation.targetObjectMetadata.id)
.filter(isDefined);
}
const targetId = field.relation?.targetObjectMetadata.id;
return targetId ? [targetId] : [];
};
@@ -0,0 +1,12 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type FieldRelationMetadataSettings } from '@/object-record/record-field/ui/types/FieldMetadata';
import { hasJunctionTargetFieldId } from './hasJunctionTargetFieldId';
type FieldMetadataItemSettings = FieldMetadataItem['settings'];
type SettingsInput =
| FieldMetadataItemSettings
| FieldRelationMetadataSettings
| undefined;
export const hasJunctionConfig = (settings: SettingsInput): boolean =>
hasJunctionTargetFieldId(settings);
@@ -0,0 +1,27 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type FieldRelationMetadataSettings } from '@/object-record/record-field/ui/types/FieldMetadata';
import { isDefined } from 'twenty-shared/utils';
type FieldMetadataItemSettings = FieldMetadataItem['settings'];
type SettingsInput =
| FieldMetadataItemSettings
| FieldRelationMetadataSettings
| undefined;
type SettingsWithJunctionTargetFieldId =
NonNullable<FieldRelationMetadataSettings> & {
junctionTargetFieldId: string;
};
export const hasJunctionTargetFieldId = (
settings: SettingsInput,
): settings is SettingsWithJunctionTargetFieldId => {
if (!isDefined(settings) || typeof settings !== 'object') {
return false;
}
return (
'junctionTargetFieldId' in settings &&
typeof settings.junctionTargetFieldId === 'string' &&
settings.junctionTargetFieldId.length > 0
);
};
@@ -0,0 +1,9 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { FieldMetadataType } from 'twenty-shared/types';
import { hasJunctionConfig } from './hasJunctionConfig';
export const isJunctionRelationField = (
fieldMetadataItem: Pick<FieldMetadataItem, 'type' | 'settings'>,
): boolean =>
fieldMetadataItem.type === FieldMetadataType.RELATION &&
hasJunctionConfig(fieldMetadataItem.settings);
@@ -0,0 +1,8 @@
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { isDefined } from 'twenty-shared/utils';
export const isObjectWithId = (value: unknown): value is ObjectRecord =>
isDefined(value) &&
typeof value === 'object' &&
'id' in value &&
typeof value.id === 'string';
@@ -0,0 +1,7 @@
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
export type ExtractedTargetRecord = {
recordId: string;
objectMetadataId: string;
record?: ObjectRecord;
};
@@ -2,6 +2,7 @@ import { css } from '@emotion/react';
import styled from '@emotion/styled';
import { FieldDisplay } from '@/object-record/record-field/ui/components/FieldDisplay';
import { useFieldFocus } from '@/object-record/record-field/ui/hooks/useFieldFocus';
import { useRecordInlineCellContext } from '@/object-record/record-inline-cell/components/RecordInlineCellContext';
import { RecordInlineCellDisplayMode } from '@/object-record/record-inline-cell/components/RecordInlineCellDisplayMode';
import { RecordInlineCellSkeletonLoader } from '@/object-record/record-inline-cell/components/RecordInlineCellSkeletonLoader';
@@ -29,7 +30,9 @@ const StyledClickableContainer = styled.div<{
`;
export const RecordInlineCellValue = () => {
const { readonly, loading, isCentered } = useRecordInlineCellContext();
const { readonly, loading, isCentered, onOpenEditMode } =
useRecordInlineCellContext();
const { isFocused } = useFieldFocus();
if (loading === true) {
return <RecordInlineCellSkeletonLoader />;
@@ -37,7 +40,10 @@ export const RecordInlineCellValue = () => {
return (
<StyledClickableContainer readonly={readonly} isCentered={isCentered}>
<RecordInlineCellDisplayMode isHovered={false}>
<RecordInlineCellDisplayMode
isHovered={isFocused}
onClick={onOpenEditMode}
>
<FieldDisplay />
</RecordInlineCellDisplayMode>
</StyledClickableContainer>
@@ -1,8 +1,10 @@
import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext';
import { isFieldFullName } from '@/object-record/record-field/ui/types/guards/isFieldFullName';
import { isFieldText } from '@/object-record/record-field/ui/types/guards/isFieldText';
import { isFieldUuid } from '@/object-record/record-field/ui/types/guards/isFieldUuid';
import { RecordTitleCellSingleTextDisplayMode } from '@/object-record/record-title-cell/components/RecordTitleCellTextFieldDisplay';
import { RecordTitleFullNameFieldDisplay } from '@/object-record/record-title-cell/components/RecordTitleFullNameFieldDisplay';
import { RecordTitleCellUuidFieldDisplay } from '@/object-record/record-title-cell/components/RecordTitleCellUuidFieldDisplay';
import { type RecordTitleCellContainerType } from '@/object-record/record-title-cell/types/RecordTitleCellContainerType';
import { useContext } from 'react';
@@ -13,8 +15,12 @@ export const RecordTitleCellFieldDisplay = ({
}) => {
const { fieldDefinition } = useContext(FieldContext);
if (!isFieldText(fieldDefinition) && !isFieldFullName(fieldDefinition)) {
throw new Error('Field definition is not a text or full name field');
if (
!isFieldText(fieldDefinition) &&
!isFieldFullName(fieldDefinition) &&
!isFieldUuid(fieldDefinition)
) {
throw new Error('Field definition is not a text, full name, or UUID field');
}
return (
@@ -23,6 +29,8 @@ export const RecordTitleCellFieldDisplay = ({
<RecordTitleCellSingleTextDisplayMode containerType={containerType} />
) : isFieldFullName(fieldDefinition) ? (
<RecordTitleFullNameFieldDisplay containerType={containerType} />
) : isFieldUuid(fieldDefinition) ? (
<RecordTitleCellUuidFieldDisplay containerType={containerType} />
) : null}
</>
);
@@ -0,0 +1,39 @@
import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { type RecordTitleCellContainerType } from '@/object-record/record-title-cell/types/RecordTitleCellContainerType';
import styled from '@emotion/styled';
import { useContext } from 'react';
import { useRecoilValue } from 'recoil';
import { OverflowingTextWithTooltip } from 'twenty-ui/display';
const StyledDiv = styled.div`
align-items: center;
background: inherit;
border: none;
border-radius: ${({ theme }) => theme.border.radius.sm};
box-sizing: border-box;
color: ${({ theme }) => theme.font.color.primary};
display: flex;
height: 24px;
justify-content: center;
overflow: hidden;
padding: ${({ theme }) => theme.spacing(0, 1.25)};
`;
export const RecordTitleCellUuidFieldDisplay = ({
containerType: _containerType,
}: {
containerType: RecordTitleCellContainerType;
}) => {
const { recordId, fieldDefinition } = useContext(FieldContext);
const recordValue = useRecoilValue(recordStoreFamilyState(recordId));
const uuidValue = recordValue?.[fieldDefinition.metadata.fieldName] ?? '';
return (
<StyledDiv>
<OverflowingTextWithTooltip text={uuidValue} />
</StyledDiv>
);
};
@@ -0,0 +1,32 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem';
import { FieldMetadataType } from 'twenty-shared/types';
type BuildRecordLabelPayloadArgs = {
id: string;
searchInput?: string;
objectMetadataItem: ObjectMetadataItem;
};
export const buildRecordLabelPayload = ({
id,
searchInput,
objectMetadataItem,
}: BuildRecordLabelPayloadArgs): Record<string, unknown> => {
const labelIdentifierField =
getLabelIdentifierFieldMetadataItem(objectMetadataItem);
if (labelIdentifierField?.type === FieldMetadataType.FULL_NAME) {
const words = searchInput?.split(' ') ?? [];
const hasMultipleWords = words.length > 1;
return {
id,
name: hasMultipleWords
? { firstName: words[0], lastName: words.slice(1).join(' ') }
: { firstName: searchInput ?? '', lastName: '' },
};
}
return { id, name: searchInput ?? '' };
};
@@ -9,9 +9,5 @@ export const filterAvailableTableColumns = (
return false;
}
if (columnDefinition.type === 'UUID') {
return false;
}
return true;
};
@@ -9,11 +9,9 @@ export const isFieldCellSupported = (
objectMetadataItems: ObjectMetadataItem[],
) => {
if (
[
FieldMetadataType.UUID,
FieldMetadataType.POSITION,
FieldMetadataType.RICH_TEXT,
].includes(fieldMetadataItem.type)
[FieldMetadataType.POSITION, FieldMetadataType.RICH_TEXT].includes(
fieldMetadataItem.type,
)
) {
return false;
}