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:
+12
@@ -166,6 +166,15 @@ export const WorkflowEditActionCreateRecord = ({
|
||||
saveAction(newFormData);
|
||||
};
|
||||
|
||||
const handleFieldClear = (fieldName: keyof CreateRecordFormData) => {
|
||||
const newFormData: CreateRecordFormData = { ...formData };
|
||||
delete newFormData[fieldName];
|
||||
|
||||
setFormData(newFormData);
|
||||
|
||||
saveAction(newFormData);
|
||||
};
|
||||
|
||||
const saveAction = useDebouncedCallback(
|
||||
async (formData: CreateRecordFormData) => {
|
||||
if (actionOptions.readonly === true) {
|
||||
@@ -242,6 +251,9 @@ export const WorkflowEditActionCreateRecord = ({
|
||||
onChange={(value) => {
|
||||
handleFieldChange(fieldDefinition.metadata.fieldName, value);
|
||||
}}
|
||||
onClear={() => {
|
||||
handleFieldClear(fieldDefinition.metadata.fieldName);
|
||||
}}
|
||||
VariablePicker={WorkflowVariablePicker}
|
||||
readonly={isFormDisabled}
|
||||
/>
|
||||
|
||||
+12
@@ -80,6 +80,15 @@ export const WorkflowEditActionUpdateRecord = ({
|
||||
saveAction(newFormData);
|
||||
};
|
||||
|
||||
const handleFieldClear = (fieldName: keyof UpdateRecordFormData) => {
|
||||
const newFormData: UpdateRecordFormData = { ...formData };
|
||||
delete newFormData[fieldName];
|
||||
|
||||
setFormData(newFormData);
|
||||
|
||||
saveAction(newFormData);
|
||||
};
|
||||
|
||||
const selectedObjectMetadataItem = activeNonSystemObjectMetadataItems.find(
|
||||
(item) => item.nameSingular === formData.objectNameSingular,
|
||||
);
|
||||
@@ -229,6 +238,9 @@ export const WorkflowEditActionUpdateRecord = ({
|
||||
onChange={(value) => {
|
||||
handleFieldChange(fieldName, value);
|
||||
}}
|
||||
onClear={() => {
|
||||
handleFieldClear(fieldName);
|
||||
}}
|
||||
VariablePicker={WorkflowVariablePicker}
|
||||
readonly={isFormDisabled}
|
||||
/>
|
||||
|
||||
+15
@@ -183,6 +183,15 @@ export const WorkflowEditActionUpsertRecord = ({
|
||||
saveAction(newFormData);
|
||||
};
|
||||
|
||||
const handleFieldClear = (fieldName: keyof UpsertRecordFormData) => {
|
||||
const newFormData: UpsertRecordFormData = { ...formData };
|
||||
delete newFormData[fieldName];
|
||||
|
||||
setFormData(newFormData);
|
||||
|
||||
saveAction(newFormData);
|
||||
};
|
||||
|
||||
const saveAction = useDebouncedCallback(
|
||||
async (formData: UpsertRecordFormData) => {
|
||||
if (actionOptions.readonly === true) {
|
||||
@@ -269,6 +278,9 @@ export const WorkflowEditActionUpsertRecord = ({
|
||||
onChange={(recordId) => {
|
||||
handleFieldChange('id', recordId);
|
||||
}}
|
||||
onClear={() => {
|
||||
handleFieldClear('id');
|
||||
}}
|
||||
objectNameSingulars={
|
||||
isDefined(objectNameSingular) ? [objectNameSingular] : []
|
||||
}
|
||||
@@ -299,6 +311,9 @@ export const WorkflowEditActionUpsertRecord = ({
|
||||
onChange={(value) => {
|
||||
handleFieldChange(fieldDefinition.metadata.fieldName, value);
|
||||
}}
|
||||
onClear={() => {
|
||||
handleFieldClear(fieldDefinition.metadata.fieldName);
|
||||
}}
|
||||
VariablePicker={WorkflowVariablePicker}
|
||||
readonly={isFormDisabled}
|
||||
/>
|
||||
|
||||
+4
-1
@@ -22,6 +22,7 @@ const SUPPORTED_FORM_FIELD_TYPES = [
|
||||
FieldMetadataType.UUID,
|
||||
FieldMetadataType.ARRAY,
|
||||
FieldMetadataType.RELATION,
|
||||
FieldMetadataType.MORPH_RELATION,
|
||||
FieldMetadataType.RICH_TEXT,
|
||||
];
|
||||
|
||||
@@ -37,8 +38,10 @@ export const shouldDisplayFormField = ({
|
||||
}
|
||||
|
||||
const isIdField = fieldMetadataItem.name === 'id';
|
||||
|
||||
const isNotSupportedRelation =
|
||||
fieldMetadataItem.type === FieldMetadataType.RELATION &&
|
||||
(fieldMetadataItem.type === FieldMetadataType.RELATION ||
|
||||
fieldMetadataItem.type === FieldMetadataType.MORPH_RELATION) &&
|
||||
fieldMetadataItem.settings?.['relationType'] !== 'MANY_TO_ONE';
|
||||
|
||||
switch (actionType) {
|
||||
|
||||
+2
@@ -63,6 +63,7 @@ export const WorkflowVariablePicker: VariablePickerComponent = ({
|
||||
onVariableSelect,
|
||||
shouldDisplayRecordObjects = false,
|
||||
shouldDisplayRecordFields = true,
|
||||
objectNameSingularsToSelect,
|
||||
}) => {
|
||||
const dropdownId = `${SEARCH_VARIABLES_DROPDOWN_ID}-${instanceId}`;
|
||||
const isDropdownOpen = useAtomComponentStateValue(
|
||||
@@ -82,6 +83,7 @@ export const WorkflowVariablePicker: VariablePickerComponent = ({
|
||||
disabled={disabled}
|
||||
shouldDisplayRecordObjects={shouldDisplayRecordObjects}
|
||||
shouldDisplayRecordFields={shouldDisplayRecordFields}
|
||||
objectNameSingularsToSelect={objectNameSingularsToSelect}
|
||||
/>
|
||||
</StyledSearchVariablesDropdownContainer>
|
||||
);
|
||||
|
||||
+3
@@ -39,6 +39,7 @@ export const WorkflowVariablesDropdown = ({
|
||||
onVariableSelect,
|
||||
shouldDisplayRecordFields,
|
||||
shouldDisplayRecordObjects,
|
||||
objectNameSingularsToSelect,
|
||||
}: {
|
||||
clickableComponent?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
@@ -47,6 +48,7 @@ export const WorkflowVariablesDropdown = ({
|
||||
onVariableSelect: (variableName: string) => void;
|
||||
shouldDisplayRecordFields: boolean;
|
||||
shouldDisplayRecordObjects: boolean;
|
||||
objectNameSingularsToSelect?: string[];
|
||||
}) => {
|
||||
const { theme } = useContext(ThemeContext);
|
||||
const dropdownId = `${SEARCH_VARIABLES_DROPDOWN_ID}-${instanceId}`;
|
||||
@@ -119,6 +121,7 @@ export const WorkflowVariablesDropdown = ({
|
||||
onSelect={handleSubItemSelect}
|
||||
onBack={handleBack}
|
||||
shouldDisplayRecordObjects={shouldDisplayRecordObjects}
|
||||
objectNameSingularsToSelect={objectNameSingularsToSelect}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
+13
-1
@@ -29,6 +29,7 @@ type WorkflowVariablesDropdownStepItemsProps = {
|
||||
onSelect: (value: string) => void;
|
||||
onBack: () => void;
|
||||
shouldDisplayRecordObjects: boolean;
|
||||
objectNameSingularsToSelect?: string[];
|
||||
};
|
||||
|
||||
export const WorkflowVariablesDropdownStepItems = ({
|
||||
@@ -36,6 +37,7 @@ export const WorkflowVariablesDropdownStepItems = ({
|
||||
onSelect,
|
||||
onBack,
|
||||
shouldDisplayRecordObjects,
|
||||
objectNameSingularsToSelect,
|
||||
}: WorkflowVariablesDropdownStepItemsProps) => {
|
||||
const { t } = useLingui();
|
||||
const { getIcon } = useIcons();
|
||||
@@ -97,8 +99,18 @@ export const WorkflowVariablesDropdownStepItems = ({
|
||||
: true;
|
||||
|
||||
const objectLabel = displayedSubStepObjectMetadata?.labelSingular;
|
||||
|
||||
const isSubStepObjectSelectable =
|
||||
!isDefined(objectNameSingularsToSelect) ||
|
||||
(isDefined(displayedSubStepObjectMetadata) &&
|
||||
objectNameSingularsToSelect.includes(
|
||||
displayedSubStepObjectMetadata.nameSingular,
|
||||
));
|
||||
|
||||
const shouldDisplaySubStepObject =
|
||||
shouldDisplayRecordObjects && isObjectFoundThroughSearch;
|
||||
shouldDisplayRecordObjects &&
|
||||
isObjectFoundThroughSearch &&
|
||||
isSubStepObjectSelectable;
|
||||
|
||||
const displayedSubStepObjectIconProps = isDefined(
|
||||
displayedSubStepObjectMetadata,
|
||||
|
||||
Reference in New Issue
Block a user