Support morph relations in workflow record nodes (#21403)
## Support morph (polymorphic) relations in workflow record nodes
Morph relations (e.g. a polymorphic `Owner` on `Pet` targeting `Person`
or `Company`) were not selectable in the workflow **Create / Update /
Upsert Record** nodes. This PR adds full support for setting them.
### What changed
**Frontend**
- `shouldDisplayFormField`: allow `MORPH_RELATION` (many-to-one) so
morph fields appear in record forms.
- New `FormMorphRelationToOneFieldInput`: a polymorphic record picker
across the morph's target objects, storing a self-describing value `{
targetObjectMetadataId, id }`.
- Wired the morph branch into `FormFieldInput`.
**Backend**
- New `formatWorkflowRecordMorphRelationFields` util: resolves the form
value (stored under the base field name, e.g. `owner`) into the correct
per-target join column (`ownerCompanyId`), nulling siblings to keep
exactly one target referenced.
- Wired into the create / update / upsert workflow actions (update also
expands `fieldsToUpdate` to the concrete join columns).
### Permissions handling
- The picker's search is scoped to only the morph targets the user can
read (`canReadObjectRecords`), so it no longer breaks when a target
object is inaccessible.
- If an existing value points to an object the user can't read, the
field shows the reused **"Not shared"** lock display instead of an empty
field, while remaining editable when other targets are readable.
### Notes
- No data schema / migration changes — reuses the existing per-target
morph columns and stores the selection in the existing workflow step
JSON settings.
<img width="607" height="717" alt="Screenshot 2026-06-10 at 14 57 40"
src="https://github.com/user-attachments/assets/496442a1-04a5-40f8-8b56-b28e38b00d5a"
/>
Also handles the case where the selected record is not readable
<img width="596" height="737" alt="image"
src="https://github.com/user-attachments/assets/c5ffb94e-3838-4db5-853e-f8e490331f23"
/>
This commit is contained in:
+161
-2
@@ -2,11 +2,44 @@ import { isObject, isString } from '@sniptt/guards';
|
||||
import { FieldMetadataType, RelationType } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { computeMorphOrRelationFieldJoinColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-morph-or-relation-field-join-column-name.util';
|
||||
import { getFlatFieldsFromFlatObjectMetadata } from 'src/engine/api/graphql/workspace-schema-builder/utils/get-flat-fields-for-flat-object-metadata.util';
|
||||
import { computeMorphOrRelationFieldJoinColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-morph-or-relation-field-join-column-name.util';
|
||||
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
|
||||
import { isFlatFieldMetadataOfType } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-flat-field-metadata-of-type.util';
|
||||
import { getMorphNameFromMorphFieldMetadataName } from 'src/engine/metadata-modules/flat-object-metadata/utils/get-morph-name-from-morph-field-metadata-name.util';
|
||||
import { type ObjectMetadataInfo } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service';
|
||||
|
||||
type MorphRelationTargetJoinColumn = {
|
||||
joinColumnName: string;
|
||||
targetObjectMetadataId: string;
|
||||
};
|
||||
|
||||
type ExtractedMorphValue = {
|
||||
targetObjectMetadataId: string;
|
||||
id: string;
|
||||
};
|
||||
|
||||
const extractMorphValue = (value: unknown): ExtractedMorphValue | null => {
|
||||
if (!isObject(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const record = value as Record<string, unknown>;
|
||||
|
||||
if (
|
||||
isString(record.targetObjectMetadataId) &&
|
||||
isString(record.id) &&
|
||||
isDefined(record.id)
|
||||
) {
|
||||
return {
|
||||
targetObjectMetadataId: record.targetObjectMetadataId,
|
||||
id: record.id,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const extractLegacyRelationId = (value: unknown): string | undefined => {
|
||||
if (!isObject(value)) {
|
||||
return undefined;
|
||||
@@ -21,7 +54,113 @@ const extractLegacyRelationId = (value: unknown): string | undefined => {
|
||||
return record.id;
|
||||
};
|
||||
|
||||
export const formatWorkflowRecordRelationFields = (
|
||||
const formatWorkflowRecordMorphRelationFields = (
|
||||
record: Record<string, unknown>,
|
||||
objectMetadataInfo: ObjectMetadataInfo,
|
||||
): {
|
||||
formattedRecord: Record<string, unknown>;
|
||||
joinColumnNamesByMorphFieldName: Record<string, string[]>;
|
||||
} => {
|
||||
const { flatObjectMetadata, flatObjectMetadataMaps, flatFieldMetadataMaps } =
|
||||
objectMetadataInfo;
|
||||
|
||||
const objectFields = getFlatFieldsFromFlatObjectMetadata(
|
||||
flatObjectMetadata,
|
||||
flatFieldMetadataMaps,
|
||||
);
|
||||
|
||||
const targetJoinColumnsByMorphFieldName = new Map<
|
||||
string,
|
||||
MorphRelationTargetJoinColumn[]
|
||||
>();
|
||||
|
||||
for (const field of objectFields) {
|
||||
if (
|
||||
!isFlatFieldMetadataOfType(field, FieldMetadataType.MORPH_RELATION) ||
|
||||
field.settings.relationType !== RelationType.MANY_TO_ONE
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const targetObjectMetadata = findFlatEntityByIdInFlatEntityMaps({
|
||||
flatEntityId: field.relationTargetObjectMetadataId,
|
||||
flatEntityMaps: flatObjectMetadataMaps,
|
||||
});
|
||||
|
||||
if (!isDefined(targetObjectMetadata)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const morphFieldName = getMorphNameFromMorphFieldMetadataName({
|
||||
morphRelationFlatFieldMetadata: field,
|
||||
nameSingular: targetObjectMetadata.nameSingular,
|
||||
namePlural: targetObjectMetadata.namePlural,
|
||||
});
|
||||
|
||||
const joinColumnName = computeMorphOrRelationFieldJoinColumnName({
|
||||
name: field.name,
|
||||
});
|
||||
|
||||
const existing =
|
||||
targetJoinColumnsByMorphFieldName.get(morphFieldName) ?? [];
|
||||
|
||||
targetJoinColumnsByMorphFieldName.set(morphFieldName, [
|
||||
...existing,
|
||||
{
|
||||
joinColumnName,
|
||||
targetObjectMetadataId: field.relationTargetObjectMetadataId,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
const formattedRecord: Record<string, unknown> = {};
|
||||
const joinColumnNamesByMorphFieldName: Record<string, string[]> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(record)) {
|
||||
const targetJoinColumns = targetJoinColumnsByMorphFieldName.get(key);
|
||||
|
||||
if (!isDefined(targetJoinColumns)) {
|
||||
formattedRecord[key] = value;
|
||||
continue;
|
||||
}
|
||||
|
||||
const registerJoinColumns = () => {
|
||||
joinColumnNamesByMorphFieldName[key] = targetJoinColumns.map(
|
||||
(targetJoinColumn) => targetJoinColumn.joinColumnName,
|
||||
);
|
||||
|
||||
for (const { joinColumnName } of targetJoinColumns) {
|
||||
formattedRecord[joinColumnName] = null;
|
||||
}
|
||||
};
|
||||
|
||||
if (value === null) {
|
||||
registerJoinColumns();
|
||||
continue;
|
||||
}
|
||||
|
||||
const morphValue = extractMorphValue(value);
|
||||
|
||||
const matchingTargetJoinColumn = isDefined(morphValue)
|
||||
? targetJoinColumns.find(
|
||||
(targetJoinColumn) =>
|
||||
targetJoinColumn.targetObjectMetadataId ===
|
||||
morphValue.targetObjectMetadataId,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
if (!isDefined(morphValue) || !isDefined(matchingTargetJoinColumn)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
registerJoinColumns();
|
||||
formattedRecord[matchingTargetJoinColumn.joinColumnName] = morphValue.id;
|
||||
}
|
||||
|
||||
return { formattedRecord, joinColumnNamesByMorphFieldName };
|
||||
};
|
||||
|
||||
const formatWorkflowRecordSimpleRelationFields = (
|
||||
record: Record<string, unknown>,
|
||||
objectMetadataInfo: ObjectMetadataInfo,
|
||||
): Record<string, unknown> => {
|
||||
@@ -70,3 +209,23 @@ export const formatWorkflowRecordRelationFields = (
|
||||
|
||||
return formattedRecord;
|
||||
};
|
||||
|
||||
export const formatWorkflowRecordRelationFields = (
|
||||
record: Record<string, unknown>,
|
||||
objectMetadataInfo: ObjectMetadataInfo,
|
||||
): {
|
||||
formattedRecord: Record<string, unknown>;
|
||||
joinColumnNamesByMorphFieldName: Record<string, string[]>;
|
||||
} => {
|
||||
const {
|
||||
formattedRecord: recordWithMorphRelations,
|
||||
joinColumnNamesByMorphFieldName,
|
||||
} = formatWorkflowRecordMorphRelationFields(record, objectMetadataInfo);
|
||||
|
||||
const formattedRecord = formatWorkflowRecordSimpleRelationFields(
|
||||
recordWithMorphRelations,
|
||||
objectMetadataInfo,
|
||||
);
|
||||
|
||||
return { formattedRecord, joinColumnNamesByMorphFieldName };
|
||||
};
|
||||
|
||||
+5
-4
@@ -59,10 +59,11 @@ export class CreateRecordWorkflowAction implements WorkflowAction {
|
||||
context,
|
||||
) as WorkflowCreateRecordActionInput;
|
||||
|
||||
const formattedObjectRecord = formatWorkflowRecordRelationFields(
|
||||
workflowActionInput.objectRecord,
|
||||
objectMetadataInfo,
|
||||
);
|
||||
const { formattedRecord: formattedObjectRecord } =
|
||||
formatWorkflowRecordRelationFields(
|
||||
workflowActionInput.objectRecord,
|
||||
objectMetadataInfo,
|
||||
);
|
||||
|
||||
const filteredObjectRecord = filterValidFieldsInRecord(
|
||||
formattedObjectRecord,
|
||||
|
||||
+9
-2
@@ -82,7 +82,10 @@ export class UpdateRecordWorkflowAction implements WorkflowAction {
|
||||
);
|
||||
}
|
||||
|
||||
const formattedObjectRecord = formatWorkflowRecordRelationFields(
|
||||
const {
|
||||
formattedRecord: formattedObjectRecord,
|
||||
joinColumnNamesByMorphFieldName,
|
||||
} = formatWorkflowRecordRelationFields(
|
||||
workflowActionInput.objectRecord,
|
||||
objectMetadataInfo,
|
||||
);
|
||||
@@ -93,7 +96,11 @@ export class UpdateRecordWorkflowAction implements WorkflowAction {
|
||||
objectMetadataInfo.flatFieldMetadataMaps,
|
||||
);
|
||||
|
||||
const filteredFieldsToUpdate = workflowActionInput.fieldsToUpdate?.filter(
|
||||
const expandedFieldsToUpdate = workflowActionInput.fieldsToUpdate?.flatMap(
|
||||
(fieldName) => joinColumnNamesByMorphFieldName[fieldName] ?? [fieldName],
|
||||
);
|
||||
|
||||
const filteredFieldsToUpdate = expandedFieldsToUpdate?.filter(
|
||||
(fieldName) => fieldName in filteredObjectRecord,
|
||||
);
|
||||
|
||||
|
||||
+5
-4
@@ -77,10 +77,11 @@ export class UpsertRecordWorkflowAction implements WorkflowAction {
|
||||
);
|
||||
}
|
||||
|
||||
const formattedObjectRecord = formatWorkflowRecordRelationFields(
|
||||
workflowActionInput.objectRecord,
|
||||
objectMetadataInfo,
|
||||
);
|
||||
const { formattedRecord: formattedObjectRecord } =
|
||||
formatWorkflowRecordRelationFields(
|
||||
workflowActionInput.objectRecord,
|
||||
objectMetadataInfo,
|
||||
);
|
||||
|
||||
const filteredObjectRecord = filterValidFieldsInRecord(
|
||||
formattedObjectRecord,
|
||||
|
||||
Reference in New Issue
Block a user