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
@@ -8,6 +8,10 @@ import { FormEmailsFieldInput } from '@/object-record/record-field/ui/form-types
import { FormFilesFieldInput } from '@/object-record/record-field/ui/form-types/components/FormFilesFieldInput';
import { FormFullNameFieldInput } from '@/object-record/record-field/ui/form-types/components/FormFullNameFieldInput';
import { FormLinksFieldInput } from '@/object-record/record-field/ui/form-types/components/FormLinksFieldInput';
import {
FormMorphRelationToOneFieldInput,
type FormMorphRelationToOneValue,
} from '@/object-record/record-field/ui/form-types/components/FormMorphRelationToOneFieldInput';
import { FormMultiSelectFieldInput } from '@/object-record/record-field/ui/form-types/components/FormMultiSelectFieldInput';
import { FormNumberFieldInput } from '@/object-record/record-field/ui/form-types/components/FormNumberFieldInput';
import { FormPhoneFieldInput } from '@/object-record/record-field/ui/form-types/components/FormPhoneFieldInput';
@@ -43,6 +47,7 @@ import { isFieldEmails } from '@/object-record/record-field/ui/types/guards/isFi
import { isFieldFiles } from '@/object-record/record-field/ui/types/guards/isFieldFiles';
import { isFieldFullName } from '@/object-record/record-field/ui/types/guards/isFieldFullName';
import { isFieldLinks } from '@/object-record/record-field/ui/types/guards/isFieldLinks';
import { isFieldMorphRelationManyToOne } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelationManyToOne';
import { isFieldMultiSelect } from '@/object-record/record-field/ui/types/guards/isFieldMultiSelect';
import { isFieldNumber } from '@/object-record/record-field/ui/types/guards/isFieldNumber';
import { isFieldPhones } from '@/object-record/record-field/ui/types/guards/isFieldPhones';
@@ -241,6 +246,16 @@ export const FormFieldInput = ({
VariablePicker={VariablePicker}
readonly={readonly}
/>
) : isFieldMorphRelationManyToOne(field) ? (
<FormMorphRelationToOneFieldInput
label={field.label}
morphRelations={field.metadata.morphRelations}
defaultValue={defaultValue as FormMorphRelationToOneValue}
onClear={onClear}
onChange={onChange}
VariablePicker={VariablePicker}
readonly={readonly}
/>
) : isFieldArray(field) ? (
<FormArrayFieldInput
label={field.label}
@@ -0,0 +1,172 @@
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { type FieldMetadataItemRelation } from '@/object-metadata/types/FieldMetadataItemRelation';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { FormFieldInputContainer } from '@/object-record/record-field/ui/form-types/components/FormFieldInputContainer';
import { FormFieldInputInnerContainer } from '@/object-record/record-field/ui/form-types/components/FormFieldInputInnerContainer';
import { FormFieldInputRowContainer } from '@/object-record/record-field/ui/form-types/components/FormFieldInputRowContainer';
import { FormFieldPlaceholder } from '@/object-record/record-field/ui/form-types/components/FormFieldPlaceholder';
import {
FormSingleRecordPicker,
type RecordId,
type Variable,
} from '@/object-record/record-field/ui/form-types/components/FormSingleRecordPicker';
import { type VariablePickerComponent } from '@/object-record/record-field/ui/form-types/types/VariablePickerComponent';
import { ForbiddenFieldDisplay } from '@/object-record/record-field/ui/meta-types/display/components/ForbiddenFieldDisplay';
import { InputLabel } from '@/ui/input/components/InputLabel';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { isString } from '@sniptt/guards';
import { useId } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { themeCssVariables } from 'twenty-ui-deprecated/theme-constants';
import { type JsonValue } from 'type-fest';
const StyledReadonlyContainer = styled.div`
cursor: default;
display: flex;
height: 32px;
width: 100%;
`;
const StyledForbiddenFieldDisplayContainer = styled.div`
display: flex;
margin: ${themeCssVariables.spacing[2]};
`;
export type FormMorphRelationToOneValue =
| {
targetObjectMetadataId: string;
id: string;
}
| Variable
| null;
type FormMorphRelationToOneFieldInputProps = {
label?: string;
morphRelations: FieldMetadataItemRelation[];
defaultValue?: FormMorphRelationToOneValue;
onChange: (value: JsonValue) => void;
onClear?: () => void;
readonly?: boolean;
testId?: string;
VariablePicker?: VariablePickerComponent;
};
export const FormMorphRelationToOneFieldInput = ({
label,
morphRelations,
defaultValue,
onChange,
onClear,
readonly,
testId,
VariablePicker,
}: FormMorphRelationToOneFieldInputProps) => {
const { objectMetadataItems } = useObjectMetadataItems();
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
const componentId = useId();
const readableObjectNameSingulars = [
...new Set(
morphRelations
.filter(
(morphRelation) =>
objectPermissionsByObjectMetadataId[
morphRelation.targetObjectMetadata.id
]?.canReadObjectRecords === true,
)
.map(
(morphRelation) => morphRelation.targetObjectMetadata.nameSingular,
),
),
];
const selectedMorphValue =
isDefined(defaultValue) && !isString(defaultValue) ? defaultValue : null;
const recordIdOrVariable: RecordId | Variable | null | undefined = isString(
defaultValue,
)
? defaultValue
: isDefined(defaultValue)
? defaultValue.id
: defaultValue;
const selectedTargetIsReadable =
!isDefined(selectedMorphValue) ||
objectPermissionsByObjectMetadataId[
selectedMorphValue.targetObjectMetadataId
]?.canReadObjectRecords === true;
const hasForbiddenSelectedRecord =
isDefined(selectedMorphValue) && !selectedTargetIsReadable;
const selectedObjectMetadataItem = isDefined(selectedMorphValue)
? objectMetadataItems.find(
(objectMetadataItem) =>
objectMetadataItem.id === selectedMorphValue.targetObjectMetadataId,
)
: undefined;
const selectedObjectNameSingular =
selectedObjectMetadataItem?.nameSingular ?? readableObjectNameSingulars[0];
if (hasForbiddenSelectedRecord) {
return (
<FormFieldInputContainer data-testid={testId}>
{label ? <InputLabel>{label}</InputLabel> : null}
<FormFieldInputRowContainer>
<StyledReadonlyContainer>
<FormFieldInputInnerContainer
formFieldInputInstanceId={componentId}
hasRightElement={false}
>
<StyledForbiddenFieldDisplayContainer>
<ForbiddenFieldDisplay />
</StyledForbiddenFieldDisplayContainer>
</FormFieldInputInnerContainer>
</StyledReadonlyContainer>
</FormFieldInputRowContainer>
</FormFieldInputContainer>
);
}
if (readableObjectNameSingulars.length === 0) {
return (
<FormFieldInputContainer data-testid={testId}>
{label ? <InputLabel>{label}</InputLabel> : null}
<FormFieldInputRowContainer>
<StyledReadonlyContainer>
<FormFieldInputInnerContainer
formFieldInputInstanceId={componentId}
hasRightElement={false}
>
<FormFieldPlaceholder>{t`No record`}</FormFieldPlaceholder>
</FormFieldInputInnerContainer>
</StyledReadonlyContainer>
</FormFieldInputRowContainer>
</FormFieldInputContainer>
);
}
return (
<FormSingleRecordPicker
label={label}
testId={testId}
defaultValue={recordIdOrVariable}
objectNameSingulars={readableObjectNameSingulars}
selectedObjectNameSingular={selectedObjectNameSingular}
onChange={onChange}
onClear={onClear}
onMorphItemSelected={(selectedMorphItem) =>
onChange({
targetObjectMetadataId: selectedMorphItem.objectMetadataId,
id: selectedMorphItem.recordId,
})
}
disabled={readonly}
VariablePicker={VariablePicker}
/>
);
};
@@ -60,6 +60,10 @@ export type FormSingleRecordPickerProps = {
onClear?: () => void;
onCreate?: (searchInput?: string) => void | Promise<void>;
objectNameSingulars: string[];
selectedObjectNameSingular?: string;
onMorphItemSelected?: (
selectedMorphItem: RecordPickerPickableMorphItem,
) => void;
disabled?: boolean;
testId?: string;
VariablePicker?: VariablePickerComponent;
@@ -69,8 +73,10 @@ export const FormSingleRecordPicker = ({
label,
defaultValue,
objectNameSingulars,
selectedObjectNameSingular,
onChange,
onClear,
onMorphItemSelected,
onCreate,
disabled,
testId,
@@ -78,6 +84,9 @@ export const FormSingleRecordPicker = ({
}: FormSingleRecordPickerProps) => {
const { theme } = useContext(ThemeContext);
const resolvedObjectNameSingular =
selectedObjectNameSingular ?? objectNameSingulars[0];
const draftValue: FormSingleRecordPickerValue =
defaultValue === null
? { type: 'no-record', value: null }
@@ -97,7 +106,7 @@ export const FormSingleRecordPicker = ({
isDefined(defaultValue) && !isStandaloneVariableString(defaultValue)
? defaultValue
: '',
objectNameSingular: objectNameSingulars[0],
objectNameSingular: resolvedObjectNameSingular,
withSoftDeleted: true,
skip: !isDefined(defaultValue) || !isValidUuid(defaultValue),
});
@@ -133,6 +142,8 @@ export const FormSingleRecordPicker = ({
if (defaultValue === selectedMorphItem.recordId) {
onClear?.();
} else if (isDefined(onMorphItemSelected)) {
onMorphItemSelected(selectedMorphItem);
} else {
onChange(selectedMorphItem.recordId);
}
@@ -185,7 +196,7 @@ export const FormSingleRecordPicker = ({
<FormSingleRecordFieldChip
draftValue={draftValue}
selectedRecord={selectedRecord}
objectNameSingular={objectNameSingulars[0]}
objectNameSingular={resolvedObjectNameSingular}
onRemove={handleUnlinkVariable}
disabled={disabled}
/>
@@ -212,7 +223,7 @@ export const FormSingleRecordPicker = ({
<FormSingleRecordFieldChip
draftValue={draftValue}
selectedRecord={selectedRecord}
objectNameSingular={objectNameSingulars[0]}
objectNameSingular={resolvedObjectNameSingular}
onRemove={handleUnlinkVariable}
disabled={disabled}
/>
@@ -248,6 +259,7 @@ export const FormSingleRecordPicker = ({
onVariableSelect={handleVariableTagInsert}
shouldDisplayRecordObjects={true}
shouldDisplayRecordFields={false}
objectNameSingularsToSelect={objectNameSingulars}
/>
)}
</FormFieldInputRowContainer>
@@ -5,4 +5,5 @@ export type VariablePickerComponent = React.FC<{
onVariableSelect: (variableName: string) => void;
shouldDisplayRecordObjects?: boolean;
shouldDisplayRecordFields?: boolean;
objectNameSingularsToSelect?: string[];
}>;