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:
Marie
2026-06-15 14:56:17 +02:00
committed by GitHub
parent 5207493cda
commit 4be76e3fd1
16 changed files with 450 additions and 18 deletions
@@ -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 };
};
@@ -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,
@@ -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,
);
@@ -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,