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:
+30
@@ -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,
|
||||
}),
|
||||
};
|
||||
};
|
||||
+6
-1
@@ -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
|
||||
|
||||
+23
-2
@@ -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);
|
||||
|
||||
|
||||
+112
@@ -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,
|
||||
};
|
||||
};
|
||||
+4
-3
@@ -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,
|
||||
|
||||
+2
-2
@@ -21,14 +21,14 @@ export const useFieldListFieldMetadataFromPosition = ({
|
||||
|
||||
const {
|
||||
inlineFieldMetadataItems,
|
||||
inlineRelationFieldMetadataItems,
|
||||
legacyActivityTargetFieldMetadataItems,
|
||||
boxedRelationFieldMetadataItems,
|
||||
} = useFieldListFieldMetadataItems({
|
||||
objectNameSingular,
|
||||
});
|
||||
|
||||
const fieldMetadataItems = [
|
||||
...inlineRelationFieldMetadataItems,
|
||||
...legacyActivityTargetFieldMetadataItems,
|
||||
...inlineFieldMetadataItems,
|
||||
...boxedRelationFieldMetadataItems,
|
||||
];
|
||||
|
||||
+21
-44
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
+37
@@ -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);
|
||||
});
|
||||
});
|
||||
+97
@@ -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,
|
||||
};
|
||||
};
|
||||
+10
-4
@@ -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),
|
||||
|
||||
+42
-4
@@ -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,
|
||||
|
||||
+145
@@ -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 };
|
||||
};
|
||||
+233
@@ -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,
|
||||
};
|
||||
};
|
||||
+111
-60
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+5
-1
@@ -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));
|
||||
|
||||
+3
-1
@@ -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;
|
||||
|
||||
|
||||
+175
-31
@@ -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'
|
||||
|
||||
+7
-23
@@ -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 =
|
||||
|
||||
+11
-2
@@ -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 & {
|
||||
|
||||
+343
@@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
+134
@@ -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');
|
||||
});
|
||||
});
|
||||
+30
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
+240
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
+83
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
+19
@@ -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);
|
||||
});
|
||||
});
|
||||
+37
@@ -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);
|
||||
});
|
||||
});
|
||||
+85
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+32
@@ -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);
|
||||
});
|
||||
});
|
||||
+138
@@ -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);
|
||||
};
|
||||
+29
@@ -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;
|
||||
};
|
||||
+77
@@ -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;
|
||||
};
|
||||
+16
@@ -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;
|
||||
};
|
||||
+98
@@ -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,
|
||||
};
|
||||
};
|
||||
+18
@@ -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);
|
||||
};
|
||||
+39
@@ -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);
|
||||
};
|
||||
+15
@@ -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] : [];
|
||||
};
|
||||
+12
@@ -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);
|
||||
+27
@@ -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
|
||||
);
|
||||
};
|
||||
+9
@@ -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);
|
||||
+8
@@ -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';
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
|
||||
|
||||
export type ExtractedTargetRecord = {
|
||||
recordId: string;
|
||||
objectMetadataId: string;
|
||||
record?: ObjectRecord;
|
||||
};
|
||||
+8
-2
@@ -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>
|
||||
|
||||
+10
-2
@@ -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}
|
||||
</>
|
||||
);
|
||||
|
||||
+39
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user