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:
+15
@@ -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}
|
||||
|
||||
+172
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+15
-3
@@ -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>
|
||||
|
||||
+1
@@ -5,4 +5,5 @@ export type VariablePickerComponent = React.FC<{
|
||||
onVariableSelect: (variableName: string) => void;
|
||||
shouldDisplayRecordObjects?: boolean;
|
||||
shouldDisplayRecordFields?: boolean;
|
||||
objectNameSingularsToSelect?: string[];
|
||||
}>;
|
||||
|
||||
Reference in New Issue
Block a user