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:
+1
@@ -17,6 +17,7 @@ export class CreateFieldInput extends OmitType(
|
||||
'updatedAt',
|
||||
'standardOverrides',
|
||||
'applicationId',
|
||||
'morphId',
|
||||
] as const,
|
||||
InputType,
|
||||
) {
|
||||
|
||||
+8
-3
@@ -24,12 +24,12 @@ import {
|
||||
} from 'class-validator';
|
||||
import { GraphQLJSON } from 'graphql-type-json';
|
||||
import {
|
||||
FieldMetadataOptions,
|
||||
FieldMetadataSettings,
|
||||
type FieldMetadataOptions,
|
||||
type FieldMetadataSettings,
|
||||
FieldMetadataType,
|
||||
} from 'twenty-shared/types';
|
||||
|
||||
import { FieldMetadataDefaultValue } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface';
|
||||
import { type FieldMetadataDefaultValue } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { IsValidMetadataName } from 'src/engine/decorators/metadata/is-valid-metadata-name.decorator';
|
||||
@@ -150,6 +150,11 @@ export class FieldMetadataDTO<T extends FieldMetadataType = FieldMetadataType> {
|
||||
@Field({ nullable: true })
|
||||
isLabelSyncedWithName?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
@Field(() => UUIDScalarType, { nullable: true })
|
||||
morphId?: string;
|
||||
|
||||
@IsDateString(undefined, {
|
||||
message: ({ value }) =>
|
||||
`Field metadata created at is invalid got ${JSON.stringify(value)} isDate: ${value instanceof Date}`,
|
||||
|
||||
+1
@@ -30,6 +30,7 @@ export class UpdateFieldInput extends OmitType(
|
||||
'isCustom',
|
||||
'standardOverrides',
|
||||
'applicationId',
|
||||
'morphId',
|
||||
] as const,
|
||||
) {
|
||||
@HideField()
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import { type AllFieldMetadataSettings } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
type JunctionTargetSettings = {
|
||||
junctionTargetFieldId?: string;
|
||||
};
|
||||
|
||||
// Extracts junction target settings from untyped settings input
|
||||
// This function handles the boundary where settings come from external API input
|
||||
export const extractJunctionTargetSettingsFromSettings = (
|
||||
settings: AllFieldMetadataSettings | null | undefined,
|
||||
): JunctionTargetSettings => {
|
||||
if (!isDefined(settings)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (typeof settings !== 'object' || settings === null) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const result: JunctionTargetSettings = {};
|
||||
|
||||
if (
|
||||
'junctionTargetFieldId' in settings &&
|
||||
typeof settings.junctionTargetFieldId === 'string'
|
||||
) {
|
||||
result.junctionTargetFieldId = settings.junctionTargetFieldId;
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
+2
@@ -26,6 +26,7 @@ export const fromFlatFieldMetadataToFieldMetadataDto = (
|
||||
isSystem,
|
||||
isUIReadOnly,
|
||||
options,
|
||||
morphId,
|
||||
applicationId,
|
||||
} = flatFieldMetadata;
|
||||
|
||||
@@ -51,6 +52,7 @@ export const fromFlatFieldMetadataToFieldMetadataDto = (
|
||||
isNullable: isNullable ?? false,
|
||||
isUnique: isUnique ?? false,
|
||||
settings: settings ?? undefined,
|
||||
morphId: morphId ?? undefined,
|
||||
applicationId: applicationId ?? undefined,
|
||||
};
|
||||
};
|
||||
|
||||
+9
@@ -10,6 +10,7 @@ import { type CreateFieldInput } from 'src/engine/metadata-modules/field-metadat
|
||||
import { type MorphOrRelationFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/types/morph-or-relation-field-metadata-type.type';
|
||||
import { computeMorphOrRelationFieldJoinColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-morph-or-relation-field-join-column-name.util';
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { extractJunctionTargetSettingsFromSettings } from 'src/engine/metadata-modules/flat-field-metadata/utils/extract-junction-target-settings-from-settings.util';
|
||||
import { generateIndexForFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/generate-index-for-flat-field-metadata.util';
|
||||
import { getDefaultFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/get-default-flat-field-metadata-from-create-field-input.util';
|
||||
import { type FlatIndexMetadata } from 'src/engine/metadata-modules/flat-index-metadata/types/flat-index-metadata.type';
|
||||
@@ -20,10 +21,12 @@ import { buildDescriptionForRelationFieldMetadataOnToField } from 'src/engine/me
|
||||
type ComputeFieldMetadataRelationSettingsForRelationTypeArgs = {
|
||||
relationType: RelationType;
|
||||
joinColumnName: string;
|
||||
junctionTargetFieldId?: string;
|
||||
};
|
||||
const computeFieldMetadataRelationSettingsForRelationType = ({
|
||||
relationType,
|
||||
joinColumnName,
|
||||
junctionTargetFieldId,
|
||||
}: ComputeFieldMetadataRelationSettingsForRelationTypeArgs) => {
|
||||
if (relationType === RelationType.MANY_TO_ONE) {
|
||||
return {
|
||||
@@ -35,6 +38,7 @@ const computeFieldMetadataRelationSettingsForRelationType = ({
|
||||
|
||||
return {
|
||||
relationType: RelationType.ONE_TO_MANY,
|
||||
...(junctionTargetFieldId && { junctionTargetFieldId }),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -73,10 +77,15 @@ export const generateMorphOrRelationFlatFieldMetadataPair = ({
|
||||
|
||||
const { relationCreationPayload } = createFieldInput;
|
||||
|
||||
const { junctionTargetFieldId } = extractJunctionTargetSettingsFromSettings(
|
||||
createFieldInput.settings,
|
||||
);
|
||||
|
||||
const sourceFlatFieldMetadataSettings =
|
||||
computeFieldMetadataRelationSettingsForRelationType({
|
||||
joinColumnName: sourceFlatObjectMetadataJoinColumnName,
|
||||
relationType: relationCreationPayload.type,
|
||||
junctionTargetFieldId,
|
||||
});
|
||||
const targetRelationTargetFieldMetadataId = v4();
|
||||
const sourceRelationTargetFieldMetadataId = v4();
|
||||
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
import { type MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { FieldMetadataType, RelationType } from 'twenty-shared/types';
|
||||
import { isDefined, isValidUuid } from 'twenty-shared/utils';
|
||||
|
||||
import { FieldMetadataExceptionCode } from 'src/engine/metadata-modules/field-metadata/field-metadata.exception';
|
||||
import { type MorphOrRelationFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/types/morph-or-relation-field-metadata-type.type';
|
||||
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
|
||||
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
|
||||
import { type FlatFieldMetadataValidationError } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata-validation-error.type';
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { isMorphOrRelationFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-morph-or-relation-flat-field-metadata.util';
|
||||
|
||||
type ValidateJunctionTargetSettingsArgs = {
|
||||
flatFieldMetadata: FlatFieldMetadata<MorphOrRelationFieldMetadataType>;
|
||||
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
|
||||
};
|
||||
|
||||
const createError = (
|
||||
code: FieldMetadataExceptionCode,
|
||||
message: string,
|
||||
userFriendlyMessage: MessageDescriptor,
|
||||
value?: string,
|
||||
): FlatFieldMetadataValidationError => ({
|
||||
code,
|
||||
message,
|
||||
userFriendlyMessage,
|
||||
...(value !== undefined && { value }),
|
||||
});
|
||||
|
||||
export const validateJunctionTargetSettings = ({
|
||||
flatFieldMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
}: ValidateJunctionTargetSettingsArgs): FlatFieldMetadataValidationError[] => {
|
||||
const { settings } = flatFieldMetadata;
|
||||
|
||||
const junctionTargetFieldId = settings.junctionTargetFieldId;
|
||||
|
||||
if (!isDefined(junctionTargetFieldId) || junctionTargetFieldId.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (settings.relationType !== RelationType.ONE_TO_MANY) {
|
||||
return [
|
||||
createError(
|
||||
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
|
||||
'Junction configuration can only be set on ONE_TO_MANY relations',
|
||||
msg`Junction configuration is only valid for ONE_TO_MANY relations`,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
if (!isValidUuid(junctionTargetFieldId)) {
|
||||
return [
|
||||
createError(
|
||||
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
|
||||
`Invalid junction target field ID: ${junctionTargetFieldId}`,
|
||||
msg`Invalid junction target field ID`,
|
||||
junctionTargetFieldId,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
const targetField = findFlatEntityByIdInFlatEntityMaps<FlatFieldMetadata>({
|
||||
flatEntityId: junctionTargetFieldId,
|
||||
flatEntityMaps: flatFieldMetadataMaps,
|
||||
});
|
||||
|
||||
if (!isDefined(targetField)) {
|
||||
return [
|
||||
createError(
|
||||
FieldMetadataExceptionCode.FIELD_METADATA_NOT_FOUND,
|
||||
`Junction target field not found: ${junctionTargetFieldId}`,
|
||||
msg`Junction target field not found`,
|
||||
junctionTargetFieldId,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
if (
|
||||
targetField.objectMetadataId !==
|
||||
flatFieldMetadata.relationTargetObjectMetadataId
|
||||
) {
|
||||
return [
|
||||
createError(
|
||||
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
|
||||
`Junction target field ${junctionTargetFieldId} is not on the junction object`,
|
||||
msg`Junction target field must be on the junction object`,
|
||||
junctionTargetFieldId,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
if (!isMorphOrRelationFlatFieldMetadata(targetField)) {
|
||||
return [
|
||||
createError(
|
||||
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
|
||||
`Junction target field ${junctionTargetFieldId} is not a relation field`,
|
||||
msg`Junction target field must be a relation field`,
|
||||
junctionTargetFieldId,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
// MORPH_RELATION fields are polymorphic and don't require MANY_TO_ONE check
|
||||
if (targetField.type === FieldMetadataType.MORPH_RELATION) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (targetField.settings.relationType !== RelationType.MANY_TO_ONE) {
|
||||
return [
|
||||
createError(
|
||||
FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
|
||||
`Junction target field ${junctionTargetFieldId} is not a MANY_TO_ONE relation`,
|
||||
msg`Junction target field must be a MANY_TO_ONE relation`,
|
||||
junctionTargetFieldId,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
+8
@@ -9,6 +9,7 @@ import { type FlatFieldMetadataTypeValidationArgs } from 'src/engine/metadata-mo
|
||||
import { type FlatFieldMetadataValidationError } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata-validation-error.type';
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { isMorphOrRelationFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-morph-or-relation-flat-field-metadata.util';
|
||||
import { validateJunctionTargetSettings } from 'src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-junction-target-settings.util';
|
||||
import { validateMorphOrRelationFlatFieldJoinColumName } from 'src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-or-relation-flat-field-join-column-name.util';
|
||||
import { validateMorphOrRelationFlatFieldOnDelete } from 'src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-or-relation-flat-field-on-delete.util';
|
||||
import { type PropertyUpdate } from 'src/engine/workspace-manager/workspace-migration/types/property-update.type';
|
||||
@@ -217,5 +218,12 @@ export const validateMorphOrRelationFlatFieldMetadata = ({
|
||||
);
|
||||
}
|
||||
|
||||
errors.push(
|
||||
...validateJunctionTargetSettings({
|
||||
flatFieldMetadata: flatFieldMetadataToValidate,
|
||||
flatFieldMetadataMaps,
|
||||
}),
|
||||
);
|
||||
|
||||
return errors;
|
||||
};
|
||||
|
||||
+10
-2
@@ -57,8 +57,17 @@ export const fromCreateObjectInputToFlatObjectMetadataAndFlatFieldMetadatasToCre
|
||||
applicationId: workspaceCustomApplicationId,
|
||||
},
|
||||
workspaceId,
|
||||
skipNameField: createObjectInput.skipNameField,
|
||||
});
|
||||
const createdAt = new Date().toISOString();
|
||||
|
||||
// Use nameField.id if it exists, otherwise use idField.id (for junction tables without name)
|
||||
const nameField = defaultFlatFieldForCustomObjectMaps.fields.nameField as
|
||||
| FlatFieldMetadata
|
||||
| undefined;
|
||||
const labelIdentifierFieldMetadataId =
|
||||
nameField?.id ?? defaultFlatFieldForCustomObjectMaps.fields.idField.id;
|
||||
|
||||
const flatObjectMetadataToCreate: FlatObjectMetadata = {
|
||||
fieldMetadataIds: [],
|
||||
viewIds: [],
|
||||
@@ -78,8 +87,7 @@ export const fromCreateObjectInputToFlatObjectMetadataAndFlatFieldMetadatasToCre
|
||||
isSearchable: true,
|
||||
isUIReadOnly: false,
|
||||
isSystem: false,
|
||||
labelIdentifierFieldMetadataId:
|
||||
defaultFlatFieldForCustomObjectMaps.fields.nameField.id,
|
||||
labelIdentifierFieldMetadataId,
|
||||
labelPlural: capitalize(createObjectInput.labelPlural),
|
||||
labelSingular: capitalize(createObjectInput.labelSingular),
|
||||
namePlural: createObjectInput.namePlural,
|
||||
|
||||
+7
-26
@@ -1,5 +1,4 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
import {
|
||||
isDefined,
|
||||
isLabelIdentifierFieldMetadataTypes,
|
||||
@@ -8,23 +7,19 @@ import {
|
||||
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
|
||||
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
|
||||
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
|
||||
import { isFlatFieldMetadataOfType } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-flat-field-metadata-of-type.util';
|
||||
import { type FlatObjectMetadataValidationError } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata-validation-error.type';
|
||||
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
|
||||
import { ObjectMetadataExceptionCode } from 'src/engine/metadata-modules/object-metadata/object-metadata.exception';
|
||||
import { type WorkspaceMigrationBuilderOptions } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/workspace-migration-builder-options.type';
|
||||
|
||||
export const validateFlatObjectMetadataIdentifiers = ({
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
buildOptions,
|
||||
}: {
|
||||
flatObjectMetadata: Pick<
|
||||
FlatObjectMetadata,
|
||||
'labelIdentifierFieldMetadataId' | 'imageIdentifierFieldMetadataId'
|
||||
>;
|
||||
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
|
||||
buildOptions: WorkspaceMigrationBuilderOptions;
|
||||
}) => {
|
||||
const errors: FlatObjectMetadataValidationError[] = [];
|
||||
|
||||
@@ -45,27 +40,13 @@ export const validateFlatObjectMetadataIdentifiers = ({
|
||||
'labelIdentifierFieldMetadataId validation failed: related field metadata not found',
|
||||
userFriendlyMessage: msg`Field declared as label identifier not found`,
|
||||
});
|
||||
} else {
|
||||
if (!isLabelIdentifierFieldMetadataTypes(flatFieldMetadata.type)) {
|
||||
errors.push({
|
||||
code: ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT,
|
||||
message:
|
||||
'labelIdentifierFieldMetadataId validation failed: field type not compatible',
|
||||
userFriendlyMessage: msg`Field cannot be used as label identifier`,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
!buildOptions.isSystemBuild &&
|
||||
isFlatFieldMetadataOfType(flatFieldMetadata, FieldMetadataType.UUID)
|
||||
) {
|
||||
errors.push({
|
||||
code: ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT,
|
||||
message:
|
||||
'labelIdentifierFieldMetadataId validation failed: field type uuid is reserved for system object metadata',
|
||||
userFriendlyMessage: msg`Field cannot be used as label identifier`,
|
||||
});
|
||||
}
|
||||
} else if (!isLabelIdentifierFieldMetadataTypes(flatFieldMetadata.type)) {
|
||||
errors.push({
|
||||
code: ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT,
|
||||
message:
|
||||
'labelIdentifierFieldMetadataId validation failed: field type not compatible',
|
||||
userFriendlyMessage: msg`Field cannot be used as label identifier`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+5
@@ -67,6 +67,11 @@ export class CreateObjectInput {
|
||||
@HideField()
|
||||
universalIdentifier?: string;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
@Field({ nullable: true })
|
||||
skipNameField?: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
@Field({ nullable: true })
|
||||
|
||||
+10
-1
@@ -375,6 +375,8 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
|
||||
objectFlatFieldMetadatas: flatFieldMetadataToCreateOnObject,
|
||||
viewId: flatDefaultViewToCreate.id,
|
||||
workspaceId,
|
||||
labelIdentifierFieldMetadataId:
|
||||
flatObjectMetadataToCreate.labelIdentifierFieldMetadataId,
|
||||
});
|
||||
|
||||
const validateAndBuildResult =
|
||||
@@ -483,14 +485,21 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
|
||||
viewId,
|
||||
workspaceId,
|
||||
workspaceCustomApplicationId,
|
||||
labelIdentifierFieldMetadataId,
|
||||
}: {
|
||||
workspaceCustomApplicationId: string;
|
||||
objectFlatFieldMetadatas: FlatFieldMetadata[];
|
||||
viewId: string;
|
||||
workspaceId: string;
|
||||
labelIdentifierFieldMetadataId: string | null;
|
||||
}) {
|
||||
const defaultViewFields = objectFlatFieldMetadatas
|
||||
.filter((field) => field.name !== 'id' && field.name !== 'deletedAt')
|
||||
.filter(
|
||||
(field) =>
|
||||
field.name !== 'deletedAt' &&
|
||||
// Include 'id' only if it's the label identifier (e.g., for junction tables)
|
||||
(field.name !== 'id' || field.id === labelIdentifierFieldMetadataId),
|
||||
)
|
||||
.map((field, index) =>
|
||||
fromCreateViewFieldInputToFlatViewFieldToCreate({
|
||||
createViewFieldInput: {
|
||||
|
||||
+44
-37
@@ -17,6 +17,7 @@ type BuildDefaultFlatFieldMetadataForCustomObjectArgs = {
|
||||
flatObjectMetadata: NonNullableRequired<
|
||||
Pick<FlatObjectMetadata, 'id' | 'applicationId'>
|
||||
>;
|
||||
skipNameField?: boolean;
|
||||
};
|
||||
|
||||
export type DefaultFlatFieldForCustomObjectMaps = ReturnType<
|
||||
@@ -26,6 +27,7 @@ export type DefaultFlatFieldForCustomObjectMaps = ReturnType<
|
||||
export const buildDefaultFlatFieldMetadatasForCustomObject = ({
|
||||
workspaceId,
|
||||
flatObjectMetadata: { id: objectMetadataId, applicationId },
|
||||
skipNameField = false,
|
||||
}: BuildDefaultFlatFieldMetadataForCustomObjectArgs) => {
|
||||
const createdAt = new Date().toISOString();
|
||||
const idFieldId = v4();
|
||||
@@ -65,42 +67,45 @@ export const buildDefaultFlatFieldMetadatasForCustomObject = ({
|
||||
applicationId,
|
||||
};
|
||||
|
||||
const nameFieldId = v4();
|
||||
const nameField: FlatFieldMetadata<FieldMetadataType.TEXT> = {
|
||||
type: FieldMetadataType.TEXT,
|
||||
id: nameFieldId,
|
||||
viewFieldIds: [],
|
||||
mainGroupByFieldMetadataViewIds: [],
|
||||
kanbanAggregateOperationViewIds: [],
|
||||
calendarViewIds: [],
|
||||
isLabelSyncedWithName: false,
|
||||
isUnique: false,
|
||||
objectMetadataId,
|
||||
universalIdentifier: nameFieldId,
|
||||
workspaceId,
|
||||
standardId: CUSTOM_OBJECT_STANDARD_FIELD_IDS.name,
|
||||
name: 'name',
|
||||
label: 'Name',
|
||||
icon: 'IconAbc',
|
||||
description: 'Name',
|
||||
isNullable: true,
|
||||
isActive: true,
|
||||
isCustom: false,
|
||||
isSystem: false,
|
||||
isUIReadOnly: false,
|
||||
defaultValue: null,
|
||||
viewFilterIds: [],
|
||||
const nameFieldId = skipNameField ? null : v4();
|
||||
const nameField: FlatFieldMetadata<FieldMetadataType.TEXT> | null =
|
||||
skipNameField
|
||||
? null
|
||||
: {
|
||||
type: FieldMetadataType.TEXT,
|
||||
id: nameFieldId!,
|
||||
viewFieldIds: [],
|
||||
mainGroupByFieldMetadataViewIds: [],
|
||||
kanbanAggregateOperationViewIds: [],
|
||||
calendarViewIds: [],
|
||||
isLabelSyncedWithName: false,
|
||||
isUnique: false,
|
||||
objectMetadataId,
|
||||
universalIdentifier: nameFieldId!,
|
||||
workspaceId,
|
||||
standardId: CUSTOM_OBJECT_STANDARD_FIELD_IDS.name,
|
||||
name: 'name',
|
||||
label: 'Name',
|
||||
icon: 'IconAbc',
|
||||
description: 'Name',
|
||||
isNullable: true,
|
||||
isActive: true,
|
||||
isCustom: false,
|
||||
isSystem: false,
|
||||
isUIReadOnly: false,
|
||||
defaultValue: null,
|
||||
viewFilterIds: [],
|
||||
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
options: null,
|
||||
standardOverrides: null,
|
||||
relationTargetFieldMetadataId: null,
|
||||
relationTargetObjectMetadataId: null,
|
||||
settings: null,
|
||||
morphId: null,
|
||||
applicationId,
|
||||
};
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
options: null,
|
||||
standardOverrides: null,
|
||||
relationTargetFieldMetadataId: null,
|
||||
relationTargetObjectMetadataId: null,
|
||||
settings: null,
|
||||
morphId: null,
|
||||
applicationId,
|
||||
};
|
||||
|
||||
const createdAtFieldId = v4();
|
||||
const createdAtField: FlatFieldMetadata<FieldMetadataType.DATE_TIME> = {
|
||||
@@ -355,7 +360,9 @@ export const buildDefaultFlatFieldMetadatasForCustomObject = ({
|
||||
relationTargetFieldMetadataId: null,
|
||||
relationTargetObjectMetadataId: null,
|
||||
settings: {
|
||||
asExpression: getTsVectorColumnExpressionFromFields([nameField]),
|
||||
asExpression: getTsVectorColumnExpressionFromFields(
|
||||
nameField ? [nameField] : [],
|
||||
),
|
||||
generatedType: 'STORED',
|
||||
},
|
||||
morphId: null,
|
||||
@@ -365,7 +372,7 @@ export const buildDefaultFlatFieldMetadatasForCustomObject = ({
|
||||
return {
|
||||
fields: {
|
||||
idField,
|
||||
nameField,
|
||||
...(nameField && { nameField }),
|
||||
createdAtField,
|
||||
updatedAtField,
|
||||
updatedByField,
|
||||
|
||||
Reference in New Issue
Block a user